diff options
Diffstat (limited to 'ui/app/pages')
39 files changed, 1644 insertions, 191 deletions
diff --git a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js index 1cbe5951d..3c4e6dcac 100644 --- a/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/app/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -18,6 +18,7 @@ import AdvancedGasInputs from '../../components/app/gas-customization/advanced-g export default class ConfirmTransactionBase extends Component { static contextTypes = { t: PropTypes.func, + tOrKey: PropTypes.func.isRequired, metricsEvent: PropTypes.func, } @@ -99,15 +100,18 @@ export default class ConfirmTransactionBase extends Component { submitError: null, } - componentDidUpdate () { + componentDidUpdate (prevProps) { const { transactionStatus, showTransactionConfirmedModal, history, clearConfirmTransaction, } = this.props + const { transactionStatus: prevTxStatus } = prevProps + const statusUpdated = transactionStatus !== prevTxStatus + const txDroppedOrConfirmed = transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS - if (transactionStatus === DROPPED_STATUS || transactionStatus === CONFIRMED_STATUS) { + if (statusUpdated && txDroppedOrConfirmed) { showTransactionConfirmedModal({ onSubmit: () => { clearConfirmTransaction() @@ -543,7 +547,8 @@ export default class ConfirmTransactionBase extends Component { toName={toName} toAddress={toAddress} showEdit={onEdit && !isTxReprice} - action={this.context.t(actionKey) || getMethodName(name) || this.context.t('contractInteraction')} + // In the event that the key is falsy (and inherently invalid), use a fallback string + action={this.context.tOrKey(actionKey) || getMethodName(name) || this.context.t('contractInteraction')} title={title} titleComponent={this.renderTitleComponent()} subtitle={subtitle} diff --git a/ui/app/pages/create-account/connect-hardware/account-list.js b/ui/app/pages/create-account/connect-hardware/account-list.js index a521c7eaf..247c27a5d 100644 --- a/ui/app/pages/create-account/connect-hardware/account-list.js +++ b/ui/app/pages/create-account/connect-hardware/account-list.js @@ -6,10 +6,6 @@ const Select = require('react-select').default import Button from '../../../components/ui/button' class AccountList extends Component { - constructor (props, context) { - super(props) - } - getHdPaths () { return [ { diff --git a/ui/app/pages/create-account/connect-hardware/connect-screen.js b/ui/app/pages/create-account/connect-hardware/connect-screen.js index f5a83e6cf..a3b8ad246 100644 --- a/ui/app/pages/create-account/connect-hardware/connect-screen.js +++ b/ui/app/pages/create-account/connect-hardware/connect-screen.js @@ -4,7 +4,7 @@ const h = require('react-hyperscript') import Button from '../../../components/ui/button' class ConnectScreen extends Component { - constructor (props, context) { + constructor (props) { super(props) this.state = { selectedDevice: null, @@ -103,7 +103,7 @@ class ConnectScreen extends Component { } - scrollToTutorial = (e) => { + scrollToTutorial = () => { if (this.referenceNode) this.referenceNode.scrollIntoView({behavior: 'smooth'}) } diff --git a/ui/app/pages/create-account/connect-hardware/index.js b/ui/app/pages/create-account/connect-hardware/index.js index 1398fa680..5a91a2725 100644 --- a/ui/app/pages/create-account/connect-hardware/index.js +++ b/ui/app/pages/create-account/connect-hardware/index.js @@ -12,7 +12,7 @@ const { getPlatform } = require('../../../../../app/scripts/lib/util') const { PLATFORM_FIREFOX } = require('../../../../../app/scripts/lib/enums') class ConnectHardwareForm extends Component { - constructor (props, context) { + constructor (props) { super(props) this.state = { error: null, @@ -101,7 +101,7 @@ class ConnectHardwareForm extends Component { const newState = { unlocked: true, device, error: null } // Default to the first account if (this.state.selectedAccount === null) { - accounts.forEach((a, i) => { + accounts.forEach((a) => { if (a.address.toLowerCase() === this.props.address) { newState.selectedAccount = a.index.toString() } diff --git a/ui/app/pages/create-account/import-account/seed.js b/ui/app/pages/create-account/import-account/seed.js index d98909baa..73332f926 100644 --- a/ui/app/pages/create-account/import-account/seed.js +++ b/ui/app/pages/create-account/import-account/seed.js @@ -11,7 +11,7 @@ SeedImportSubview.contextTypes = { module.exports = connect(mapStateToProps)(SeedImportSubview) -function mapStateToProps (state) { +function mapStateToProps () { return {} } diff --git a/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js index ffaff9acf..6b9d06cf9 100644 --- a/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js +++ b/ui/app/pages/first-time-flow/metametrics-opt-in/metametrics-opt-in.component.js @@ -119,7 +119,7 @@ export default class MetaMetricsOptIn extends Component { hideCancel={false} onSubmit={() => { setParticipateInMetaMetrics(true) - .then(([participateStatus, metaMetricsId]) => { + .then(([_, metaMetricsId]) => { const promise = participateInMetaMetrics !== true ? metricsEvent({ eventOpts: { diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js index f3bfc3171..4cfc38fdf 100644 --- a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.component.js @@ -8,7 +8,9 @@ import { INITIALIZE_SEED_PHRASE_ROUTE, } from '../../../../helpers/constants/routes' import { exportAsFile } from '../../../../helpers/utils/util' -import { selectSeedWord, deselectSeedWord } from './confirm-seed-phrase.state' +import DraggableSeed from './draggable-seed.component' + +const EMPTY_SEEDS = Array(12).fill(null) export default class ConfirmSeedPhrase extends PureComponent { static contextTypes = { @@ -27,10 +29,32 @@ export default class ConfirmSeedPhrase extends PureComponent { } state = { - selectedSeedWords: [], + selectedSeedIndices: [], shuffledSeedWords: [], - // Hash of shuffledSeedWords index {Number} to selectedSeedWords index {Number} - selectedSeedWordsHash: {}, + pendingSeedIndices: [], + draggingSeedIndex: -1, + hoveringIndex: -1, + isDragging: false, + } + + shouldComponentUpdate (nextProps, nextState) { + const { seedPhrase } = this.props + const { + selectedSeedIndices, + shuffledSeedWords, + pendingSeedIndices, + draggingSeedIndex, + hoveringIndex, + isDragging, + } = this.state + + return seedPhrase !== nextProps.seedPhrase || + draggingSeedIndex !== nextState.draggingSeedIndex || + isDragging !== nextState.isDragging || + hoveringIndex !== nextState.hoveringIndex || + selectedSeedIndices.join(' ') !== nextState.selectedSeedIndices.join(' ') || + shuffledSeedWords.join(' ') !== nextState.shuffledSeedWords.join(' ') || + pendingSeedIndices.join(' ') !== nextState.pendingSeedIndices.join(' ') } componentDidMount () { @@ -39,6 +63,26 @@ export default class ConfirmSeedPhrase extends PureComponent { this.setState({ shuffledSeedWords }) } + setDraggingSeedIndex = draggingSeedIndex => this.setState({ draggingSeedIndex }) + + setHoveringIndex = hoveringIndex => this.setState({ hoveringIndex }) + + onDrop = targetIndex => { + const { + selectedSeedIndices, + draggingSeedIndex, + } = this.state + + const indices = insert(selectedSeedIndices, draggingSeedIndex, targetIndex, true) + + this.setState({ + selectedSeedIndices: indices, + pendingSeedIndices: indices, + draggingSeedIndex: -1, + hoveringIndex: -1, + }) + } + handleExport = () => { exportAsFile('MetaMask Secret Backup Phrase', this.props.seedPhrase, 'text/plain') } @@ -64,24 +108,35 @@ export default class ConfirmSeedPhrase extends PureComponent { } } - handleSelectSeedWord = (word, shuffledIndex) => { - this.setState(selectSeedWord(word, shuffledIndex)) + handleSelectSeedWord = (shuffledIndex) => { + this.setState({ + selectedSeedIndices: [...this.state.selectedSeedIndices, shuffledIndex], + pendingSeedIndices: [...this.state.pendingSeedIndices, shuffledIndex], + }) } handleDeselectSeedWord = shuffledIndex => { - this.setState(deselectSeedWord(shuffledIndex)) + this.setState({ + selectedSeedIndices: this.state.selectedSeedIndices.filter(i => shuffledIndex !== i), + pendingSeedIndices: this.state.pendingSeedIndices.filter(i => shuffledIndex !== i), + }) } isValid () { const { seedPhrase } = this.props - const { selectedSeedWords } = this.state + const { selectedSeedIndices, shuffledSeedWords } = this.state + const selectedSeedWords = selectedSeedIndices.map(i => shuffledSeedWords[i]) return seedPhrase === selectedSeedWords.join(' ') } render () { const { t } = this.context const { history } = this.props - const { selectedSeedWords, shuffledSeedWords, selectedSeedWordsHash } = this.state + const { + selectedSeedIndices, + shuffledSeedWords, + draggingSeedIndex, + } = this.state return ( <div className="confirm-seed-phrase"> @@ -102,41 +157,39 @@ export default class ConfirmSeedPhrase extends PureComponent { <div className="first-time-flow__text-block"> { t('selectEachPhrase') } </div> - <div className="confirm-seed-phrase__selected-seed-words"> - { - selectedSeedWords.map((word, index) => ( - <div - key={index} - className="confirm-seed-phrase__seed-word" - > - { word } - </div> - )) - } + <div + className={classnames('confirm-seed-phrase__selected-seed-words', { + 'confirm-seed-phrase__selected-seed-words--dragging': draggingSeedIndex > -1, + })} + > + { this.renderPendingSeeds() } + { this.renderSelectedSeeds() } </div> <div className="confirm-seed-phrase__shuffled-seed-words"> { shuffledSeedWords.map((word, index) => { - const isSelected = index in selectedSeedWordsHash + const isSelected = selectedSeedIndices.includes(index) return ( - <div + <DraggableSeed key={index} - className={classnames( - 'confirm-seed-phrase__seed-word', - 'confirm-seed-phrase__seed-word--shuffled', - { 'confirm-seed-phrase__seed-word--selected': isSelected } - )} + seedIndex={index} + index={index} + draggingSeedIndex={this.state.draggingSeedIndex} + setDraggingSeedIndex={this.setDraggingSeedIndex} + setHoveringIndex={this.setHoveringIndex} + onDrop={this.onDrop} + className="confirm-seed-phrase__seed-word--shuffled" + selected={isSelected} onClick={() => { if (!isSelected) { - this.handleSelectSeedWord(word, index) + this.handleSelectSeedWord(index) } else { this.handleDeselectSeedWord(index) } }} - > - { word } - </div> + word={word} + /> ) }) } @@ -152,4 +205,80 @@ export default class ConfirmSeedPhrase extends PureComponent { </div> ) } + + renderSelectedSeeds () { + const { shuffledSeedWords, selectedSeedIndices, draggingSeedIndex } = this.state + return EMPTY_SEEDS.map((_, index) => { + const seedIndex = selectedSeedIndices[index] + const word = shuffledSeedWords[seedIndex] + + return ( + <DraggableSeed + key={`selected-${seedIndex}-${index}`} + className="confirm-seed-phrase__selected-seed-words__selected-seed" + index={index} + seedIndex={seedIndex} + word={word} + draggingSeedIndex={draggingSeedIndex} + setDraggingSeedIndex={this.setDraggingSeedIndex} + setHoveringIndex={this.setHoveringIndex} + onDrop={this.onDrop} + draggable + /> + ) + }) + } + + renderPendingSeeds () { + const { + pendingSeedIndices, + shuffledSeedWords, + draggingSeedIndex, + hoveringIndex, + } = this.state + + const indices = insert(pendingSeedIndices, draggingSeedIndex, hoveringIndex) + + return EMPTY_SEEDS.map((_, index) => { + const seedIndex = indices[index] + const word = shuffledSeedWords[seedIndex] + + return ( + <DraggableSeed + key={`pending-${seedIndex}-${index}`} + index={index} + className={classnames('confirm-seed-phrase__selected-seed-words__pending-seed', { + 'confirm-seed-phrase__seed-word--hidden': draggingSeedIndex === seedIndex && index !== hoveringIndex, + })} + seedIndex={seedIndex} + word={word} + draggingSeedIndex={draggingSeedIndex} + setDraggingSeedIndex={this.setDraggingSeedIndex} + setHoveringIndex={this.setHoveringIndex} + onDrop={this.onDrop} + droppable={!!word} + /> + ) + }) + } +} + +function insert (list, value, target, removeOld) { + let nextList = [...list] + + if (typeof list[target] === 'number') { + nextList = [...list.slice(0, target), value, ...list.slice(target)] + } + + if (removeOld) { + nextList = nextList.filter((seed, i) => { + return seed !== value || i === target + }) + } + + if (nextList.length > 12) { + nextList.pop() + } + + return nextList } diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js deleted file mode 100644 index f2476fc5c..000000000 --- a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/confirm-seed-phrase.state.js +++ /dev/null @@ -1,41 +0,0 @@ -export function selectSeedWord (word, shuffledIndex) { - return function update (state) { - const { selectedSeedWords, selectedSeedWordsHash } = state - const nextSelectedIndex = selectedSeedWords.length - - return { - selectedSeedWords: [ ...selectedSeedWords, word ], - selectedSeedWordsHash: { ...selectedSeedWordsHash, [shuffledIndex]: nextSelectedIndex }, - } - } -} - -export function deselectSeedWord (shuffledIndex) { - return function update (state) { - const { - selectedSeedWords: prevSelectedSeedWords, - selectedSeedWordsHash: prevSelectedSeedWordsHash, - } = state - - const selectedSeedWords = [...prevSelectedSeedWords] - const indexToRemove = prevSelectedSeedWordsHash[shuffledIndex] - selectedSeedWords.splice(indexToRemove, 1) - const selectedSeedWordsHash = Object.keys(prevSelectedSeedWordsHash).reduce((acc, index) => { - const output = { ...acc } - const selectedSeedWordIndex = prevSelectedSeedWordsHash[index] - - if (selectedSeedWordIndex < indexToRemove) { - output[index] = selectedSeedWordIndex - } else if (selectedSeedWordIndex > indexToRemove) { - output[index] = selectedSeedWordIndex - 1 - } - - return output - }, {}) - - return { - selectedSeedWords, - selectedSeedWordsHash, - } - } -} diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/draggable-seed.component.js b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/draggable-seed.component.js new file mode 100644 index 000000000..cdb881921 --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/draggable-seed.component.js @@ -0,0 +1,126 @@ +import React, { Component } from 'react' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import { DragSource, DropTarget } from 'react-dnd' + +class DraggableSeed extends Component { + + static propTypes = { + // React DnD Props + connectDragSource: PropTypes.func.isRequired, + connectDropTarget: PropTypes.func.isRequired, + isDragging: PropTypes.bool, + isOver: PropTypes.bool, + canDrop: PropTypes.bool, + // Own Props + onClick: PropTypes.func.isRequired, + setHoveringIndex: PropTypes.func.isRequired, + index: PropTypes.number, + draggingSeedIndex: PropTypes.number, + word: PropTypes.string, + className: PropTypes.string, + selected: PropTypes.bool, + droppable: PropTypes.bool, + } + + static defaultProps = { + className: '', + onClick () {}, + } + + componentWillReceiveProps (nextProps) { + const { isOver, setHoveringIndex } = this.props + if (isOver && !nextProps.isOver) { + setHoveringIndex(-1) + } + } + + render () { + const { + connectDragSource, + connectDropTarget, + isDragging, + index, + word, + selected, + className, + onClick, + isOver, + canDrop, + } = this.props + + return connectDropTarget(connectDragSource( + <div + key={index} + className={classnames('btn-secondary confirm-seed-phrase__seed-word', className, { + 'confirm-seed-phrase__seed-word--selected btn-primary': selected, + 'confirm-seed-phrase__seed-word--dragging': isDragging, + 'confirm-seed-phrase__seed-word--empty': !word, + 'confirm-seed-phrase__seed-word--active-drop': !isOver && canDrop, + 'confirm-seed-phrase__seed-word--drop-hover': isOver && canDrop, + })} + onClick={onClick} + > + { word } + </div> + )) + } +} + +const SEEDWORD = 'SEEDWORD' + +const seedSource = { + beginDrag (props) { + setTimeout(() => props.setDraggingSeedIndex(props.seedIndex), 0) + return { + seedIndex: props.seedIndex, + word: props.word, + } + }, + canDrag (props) { + return props.draggable + }, + endDrag (props, monitor) { + const dropTarget = monitor.getDropResult() + + if (!dropTarget) { + setTimeout(() => props.setDraggingSeedIndex(-1), 0) + return + } + + props.onDrop(dropTarget.targetIndex) + }, +} + +const seedTarget = { + drop (props) { + return { + targetIndex: props.index, + } + }, + canDrop (props) { + return props.droppable + }, + hover (props) { + props.setHoveringIndex(props.index) + }, +} + +const collectDrag = (connect, monitor) => { + return { + connectDragSource: connect.dragSource(), + isDragging: monitor.isDragging(), + } +} + +const collectDrop = (connect, monitor) => { + return { + connectDropTarget: connect.dropTarget(), + isOver: monitor.isOver(), + canDrop: monitor.canDrop(), + } +} + +export default DropTarget(SEEDWORD, seedTarget, collectDrop)(DragSource(SEEDWORD, seedSource, collectDrag)(DraggableSeed)) + + diff --git a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss index 93137618c..f025a503f 100644 --- a/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss +++ b/ui/app/pages/first-time-flow/seed-phrase/confirm-seed-phrase/index.scss @@ -3,37 +3,58 @@ margin-bottom: 12px; } - &__selected-seed-words { - min-height: 190px; - max-width: 496px; - border: 1px solid #CDCDCD; - border-radius: 6px; - background-color: $white; - margin: 24px 0 36px; - padding: 12px; - } - &__shuffled-seed-words { - max-width: 496px; + max-width: 575px; } &__seed-word { - display: inline-block; - color: #5B5D67; - background-color: #E7E7E7; + display: inline-flex; + flex-flow: row nowrap; + align-items: center; + justify-content: center; padding: 8px 18px; - min-width: 64px; + width: 128px; + height: 41px; margin: 4px; text-align: center; + border-radius: 4px; + cursor: move; + + &--shuffled { + cursor: pointer; + margin: 6px; + } &--selected { - background-color: #85D1CC; color: $white; } - &--shuffled { - cursor: pointer; - margin: 6px; + &--dragging { + margin: 0; + } + + &--empty { + background-color: transparent; + border-color: transparent; + cursor: default; + + &:hover, + &:active { + background-color: transparent; + border-color: transparent; + cursor: default; + box-shadow: none !important; + } + } + + &--hidden { + display: none !important; + } + + &--drop-hover { + background-color: transparent; + border-color: transparent; + color: transparent; } @media screen and (max-width: 575px) { @@ -42,7 +63,37 @@ } } - button { - margin-top: 0xp; + &__selected-seed-words { + display: flex; + flex-flow: row wrap; + min-height: 161px; + max-width: 575px; + border: 1px solid #CDCDCD; + border-radius: 6px; + background-color: $white; + margin: 24px 0 36px; + padding: 12px; + + &__pending-seed { + display: none; + } + + &__selected-seed { + display: inline-flex; + + &:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25); + } + } + + &--dragging { + .confirm-seed-phrase__selected-seed-words__pending-seed { + display: inline-flex; + } + + .confirm-seed-phrase__selected-seed-words__selected-seed { + display: none; + } + } } } diff --git a/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js b/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js index 9a9f84049..0b19af18c 100644 --- a/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js +++ b/ui/app/pages/first-time-flow/seed-phrase/seed-phrase.component.js @@ -8,6 +8,8 @@ import { INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE, DEFAULT_ROUTE, } from '../../../helpers/constants/routes' +import HTML5Backend from 'react-dnd-html5-backend' +import {DragDropContextProvider} from 'react-dnd' export default class SeedPhrase extends PureComponent { static propTypes = { @@ -28,43 +30,45 @@ export default class SeedPhrase extends PureComponent { const { seedPhrase } = this.props return ( - <div className="first-time-flow__wrapper"> - <div className="app-header__logo-container"> - <img - className="app-header__metafox-logo app-header__metafox-logo--horizontal" - src="/images/logo/metamask-logo-horizontal.svg" - height={30} - /> - <img - className="app-header__metafox-logo app-header__metafox-logo--icon" - src="/images/logo/metamask-fox.svg" - height={42} - width={42} - /> + <DragDropContextProvider backend={HTML5Backend}> + <div className="first-time-flow__wrapper"> + <div className="app-header__logo-container"> + <img + className="app-header__metafox-logo app-header__metafox-logo--horizontal" + src="/images/logo/metamask-logo-horizontal.svg" + height={30} + /> + <img + className="app-header__metafox-logo app-header__metafox-logo--icon" + src="/images/logo/metamask-fox.svg" + height={42} + width={42} + /> + </div> + <Switch> + <Route + exact + path={INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE} + render={props => ( + <ConfirmSeedPhrase + { ...props } + seedPhrase={seedPhrase} + /> + )} + /> + <Route + exact + path={INITIALIZE_SEED_PHRASE_ROUTE} + render={props => ( + <RevealSeedPhrase + { ...props } + seedPhrase={seedPhrase} + /> + )} + /> + </Switch> </div> - <Switch> - <Route - exact - path={INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE} - render={props => ( - <ConfirmSeedPhrase - { ...props } - seedPhrase={seedPhrase} - /> - )} - /> - <Route - exact - path={INITIALIZE_SEED_PHRASE_ROUTE} - render={props => ( - <RevealSeedPhrase - { ...props } - seedPhrase={seedPhrase} - /> - )} - /> - </Switch> - </div> + </DragDropContextProvider> ) } } diff --git a/ui/app/pages/first-time-flow/seed-phrase/tests/confirm-seed-phrase-component.test.js b/ui/app/pages/first-time-flow/seed-phrase/tests/confirm-seed-phrase-component.test.js new file mode 100644 index 000000000..8339a6f6f --- /dev/null +++ b/ui/app/pages/first-time-flow/seed-phrase/tests/confirm-seed-phrase-component.test.js @@ -0,0 +1,169 @@ +import React from 'react' +import assert from 'assert' +import { shallow } from 'enzyme' +import sinon from 'sinon' +import ConfirmSeedPhrase from '../confirm-seed-phrase/confirm-seed-phrase.component' + +function shallowRender (props = {}, context = {}) { + return shallow( + <ConfirmSeedPhrase {...props} />, + { + context: { + t: str => str + '_t', + ...context, + }, + } + ) +} + +describe('ConfirmSeedPhrase Component', () => { + it('should render correctly', () => { + const root = shallowRender({ + seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬', + }) + + assert.equal( + root.find('.confirm-seed-phrase__seed-word--shuffled').length, + 12, + 'should render 12 seed phrases' + ) + }) + + it('should add/remove selected on click', () => { + const metricsEventSpy = sinon.spy() + const pushSpy = sinon.spy() + const root = shallowRender( + { + seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬', + history: { push: pushSpy }, + }, + { + metricsEvent: metricsEventSpy, + } + ) + + const seeds = root.find('.confirm-seed-phrase__seed-word--shuffled') + + // Click on 3 seeds to add to selected + seeds.at(0).simulate('click') + seeds.at(1).simulate('click') + seeds.at(2).simulate('click') + + assert.deepEqual( + root.state().selectedSeedIndices, + [0, 1, 2], + 'should add seed phrase to selected on click', + ) + + // Click on a selected seed to remove + root.state() + root.update() + root.state() + root.find('.confirm-seed-phrase__seed-word--shuffled').at(1).simulate('click') + assert.deepEqual( + root.state().selectedSeedIndices, + [0, 2], + 'should remove seed phrase from selected when click again', + ) + }) + + it('should render correctly on hover', () => { + const metricsEventSpy = sinon.spy() + const pushSpy = sinon.spy() + const root = shallowRender( + { + seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬', + history: { push: pushSpy }, + }, + { + metricsEvent: metricsEventSpy, + } + ) + + const seeds = root.find('.confirm-seed-phrase__seed-word--shuffled') + + // Click on 3 seeds to add to selected + seeds.at(0).simulate('click') + seeds.at(1).simulate('click') + seeds.at(2).simulate('click') + + // Dragging Seed # 2 to 0 placeth + root.instance().setDraggingSeedIndex(2) + root.instance().setHoveringIndex(0) + + root.update() + + const pendingSeeds = root.find('.confirm-seed-phrase__selected-seed-words__pending-seed') + + assert.equal(pendingSeeds.at(0).props().seedIndex, 2) + assert.equal(pendingSeeds.at(1).props().seedIndex, 0) + assert.equal(pendingSeeds.at(2).props().seedIndex, 1) + }) + + it('should insert seed in place on drop', () => { + const metricsEventSpy = sinon.spy() + const pushSpy = sinon.spy() + const root = shallowRender( + { + seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬', + history: { push: pushSpy }, + }, + { + metricsEvent: metricsEventSpy, + } + ) + + const seeds = root.find('.confirm-seed-phrase__seed-word--shuffled') + + // Click on 3 seeds to add to selected + seeds.at(0).simulate('click') + seeds.at(1).simulate('click') + seeds.at(2).simulate('click') + + // Drop Seed # 2 to 0 placeth + root.instance().setDraggingSeedIndex(2) + root.instance().setHoveringIndex(0) + root.instance().onDrop(0) + + root.update() + + assert.deepEqual(root.state().selectedSeedIndices, [2, 0, 1]) + assert.deepEqual(root.state().pendingSeedIndices, [2, 0, 1]) + }) + + it('should submit correctly', () => { + const originalSeed = ['鼠', '牛', '虎', '兔', '龍', '蛇', '馬', '羊', '猴', '雞', '狗', '豬'] + const metricsEventSpy = sinon.spy() + const pushSpy = sinon.spy() + const root = shallowRender( + { + seedPhrase: '鼠 牛 虎 兔 龍 蛇 馬 羊 猴 雞 狗 豬', + history: { push: pushSpy }, + }, + { + metricsEvent: metricsEventSpy, + } + ) + + const shuffled = root.state().shuffledSeedWords + const seeds = root.find('.confirm-seed-phrase__seed-word--shuffled') + + + originalSeed.forEach(seed => { + const seedIndex = shuffled.findIndex(s => s === seed) + seeds.at(seedIndex).simulate('click') + }) + + root.update() + + root.find('.first-time-flow__button').simulate('click') + assert.deepEqual(metricsEventSpy.args[0][0], { + eventOpts: { + category: 'Onboarding', + action: 'Seed Phrase Setup', + name: 'Verify Complete', + }, + }) + assert.equal(pushSpy.args[0][0], '/initialize/end-of-flow') + }) +}) diff --git a/ui/app/pages/home/home.component.js b/ui/app/pages/home/home.component.js index 29d93a9fa..4d96c3131 100644 --- a/ui/app/pages/home/home.component.js +++ b/ui/app/pages/home/home.component.js @@ -23,21 +23,27 @@ export default class Home extends PureComponent { providerRequests: PropTypes.array, } + componentWillMount () { + const { + history, + unconfirmedTransactionsCount = 0, + } = this.props + + if (unconfirmedTransactionsCount > 0) { + history.push(CONFIRM_TRANSACTION_ROUTE) + } + } + componentDidMount () { const { history, suggestedTokens = {}, - unconfirmedTransactionsCount = 0, } = this.props // suggested new tokens if (Object.keys(suggestedTokens).length > 0) { history.push(CONFIRM_ADD_SUGGESTED_TOKEN_ROUTE) } - - if (unconfirmedTransactionsCount > 0) { - history.push(CONFIRM_TRANSACTION_ROUTE) - } } render () { @@ -45,6 +51,7 @@ export default class Home extends PureComponent { forgottenPassword, seedWords, providerRequests, + history, } = this.props // seed words @@ -69,7 +76,7 @@ export default class Home extends PureComponent { query="(min-width: 576px)" render={() => <WalletView />} /> - <TransactionView /> + { !history.location.pathname.match(/^\/confirm-transaction/) ? <TransactionView /> : null } </div> </div> ) diff --git a/ui/app/pages/provider-approval/provider-approval.component.js b/ui/app/pages/provider-approval/provider-approval.component.js index 1f1d68da7..70d3d0007 100644 --- a/ui/app/pages/provider-approval/provider-approval.component.js +++ b/ui/app/pages/provider-approval/provider-approval.component.js @@ -4,9 +4,9 @@ import ProviderPageContainer from '../../components/app/provider-page-container' export default class ProviderApproval extends Component { static propTypes = { - approveProviderRequest: PropTypes.func.isRequired, + approveProviderRequestByOrigin: PropTypes.func.isRequired, + rejectProviderRequestByOrigin: PropTypes.func.isRequired, providerRequest: PropTypes.object.isRequired, - rejectProviderRequest: PropTypes.func.isRequired, }; static contextTypes = { @@ -14,13 +14,13 @@ export default class ProviderApproval extends Component { }; render () { - const { approveProviderRequest, providerRequest, rejectProviderRequest } = this.props + const { approveProviderRequestByOrigin, providerRequest, rejectProviderRequestByOrigin } = this.props return ( <ProviderPageContainer - approveProviderRequest={approveProviderRequest} + approveProviderRequestByOrigin={approveProviderRequestByOrigin} + rejectProviderRequestByOrigin={rejectProviderRequestByOrigin} origin={providerRequest.origin} tabID={providerRequest.tabID} - rejectProviderRequest={rejectProviderRequest} siteImage={providerRequest.siteImage} siteTitle={providerRequest.siteTitle} /> diff --git a/ui/app/pages/provider-approval/provider-approval.container.js b/ui/app/pages/provider-approval/provider-approval.container.js index d53c0ae4d..1e167ddb7 100644 --- a/ui/app/pages/provider-approval/provider-approval.container.js +++ b/ui/app/pages/provider-approval/provider-approval.container.js @@ -1,11 +1,11 @@ import { connect } from 'react-redux' import ProviderApproval from './provider-approval.component' -import { approveProviderRequest, rejectProviderRequest } from '../../store/actions' +import { approveProviderRequestByOrigin, rejectProviderRequestByOrigin } from '../../store/actions' function mapDispatchToProps (dispatch) { return { - approveProviderRequest: tabID => dispatch(approveProviderRequest(tabID)), - rejectProviderRequest: tabID => dispatch(rejectProviderRequest(tabID)), + approveProviderRequestByOrigin: origin => dispatch(approveProviderRequestByOrigin(origin)), + rejectProviderRequestByOrigin: origin => dispatch(rejectProviderRequestByOrigin(origin)), } } diff --git a/ui/app/pages/routes/index.js b/ui/app/pages/routes/index.js index e38a6d6ce..9eeac2da2 100644 --- a/ui/app/pages/routes/index.js +++ b/ui/app/pages/routes/index.js @@ -5,7 +5,8 @@ import { Route, Switch, withRouter, matchPath } from 'react-router-dom' import { compose } from 'recompose' import actions from '../../store/actions' import log from 'loglevel' -import { getMetaMaskAccounts, getNetworkIdentifier } from '../../selectors/selectors' +import IdleTimer from 'react-idle-timer' +import {getMetaMaskAccounts, getNetworkIdentifier, preferencesSelector} from '../../selectors/selectors' // init import FirstTimeFlow from '../first-time-flow' @@ -98,7 +99,9 @@ class Routes extends Component { } renderRoutes () { - return ( + const { autoLogoutTimeLimit, setLastActiveTime } = this.props + + const routes = ( <Switch> <Route path={LOCK_ROUTE} component={Lock} exact /> <Route path={INITIALIZE_ROUTE} component={FirstTimeFlow} /> @@ -116,6 +119,16 @@ class Routes extends Component { <Authenticated path={DEFAULT_ROUTE} component={Home} exact /> </Switch> ) + + if (autoLogoutTimeLimit > 0) { + return ( + <IdleTimer onAction={setLastActiveTime} throttle={1000}> + {routes} + </IdleTimer> + ) + } + + return routes } onInitializationUnlockPage () { @@ -322,6 +335,7 @@ Routes.propTypes = { networkDropdownOpen: PropTypes.bool, showNetworkDropdown: PropTypes.func, hideNetworkDropdown: PropTypes.func, + setLastActiveTime: PropTypes.func, history: PropTypes.object, location: PropTypes.object, dispatch: PropTypes.func, @@ -344,6 +358,7 @@ Routes.propTypes = { t: PropTypes.func, providerId: PropTypes.string, providerRequests: PropTypes.array, + autoLogoutTimeLimit: PropTypes.number, } function mapStateToProps (state) { @@ -358,6 +373,7 @@ function mapStateToProps (state) { } = appState const accounts = getMetaMaskAccounts(state) + const { autoLogoutTimeLimit = 0 } = preferencesSelector(state) const { identities, @@ -409,6 +425,7 @@ function mapStateToProps (state) { Qr: state.appState.Qr, welcomeScreenSeen: state.metamask.welcomeScreenSeen, providerId: getNetworkIdentifier(state), + autoLogoutTimeLimit, // state needed to get account dropdown temporarily rendering from app bar identities, @@ -418,7 +435,7 @@ function mapStateToProps (state) { } } -function mapDispatchToProps (dispatch, ownProps) { +function mapDispatchToProps (dispatch) { return { dispatch, hideSidebar: () => dispatch(actions.hideSidebar()), @@ -427,6 +444,7 @@ function mapDispatchToProps (dispatch, ownProps) { setCurrentCurrencyToUSD: () => dispatch(actions.setCurrentCurrency('usd')), toggleAccountMenu: () => dispatch(actions.toggleAccountMenu()), setMouseUserState: (isMouseUser) => dispatch(actions.setMouseUserState(isMouseUser)), + setLastActiveTime: () => dispatch(actions.setLastActiveTime()), } } diff --git a/ui/app/pages/send/account-list-item/tests/account-list-item-container.test.js b/ui/app/pages/send/account-list-item/tests/account-list-item-container.test.js index 33f932daf..1580fd497 100644 --- a/ui/app/pages/send/account-list-item/tests/account-list-item-container.test.js +++ b/ui/app/pages/send/account-list-item/tests/account-list-item-container.test.js @@ -5,7 +5,7 @@ let mapStateToProps proxyquire('../account-list-item.container.js', { 'react-redux': { - connect: (ms, md) => { + connect: (ms) => { mapStateToProps = ms return () => ({}) }, diff --git a/ui/app/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/app/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js index f17137c1e..e256d1442 100644 --- a/ui/app/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ b/ui/app/pages/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js @@ -15,6 +15,7 @@ export default class AmountMaxButton extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } setMaxAmount () { @@ -35,11 +36,15 @@ export default class AmountMaxButton extends Component { } onMaxClick = (event) => { - const { setMaxModeTo, selectedToken } = this.props + const { setMaxModeTo } = this.props + const { metricsEvent } = this.context - fetch('https://chromeextensionmm.innocraft.cloud/piwik.php?idsite=1&rec=1&e_c=send&e_a=amountMax&e_n=' + (selectedToken ? 'token' : 'eth'), { - 'headers': {}, - 'method': 'GET', + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Clicked "Amount Max"', + }, }) event.preventDefault() diff --git a/ui/app/pages/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js b/ui/app/pages/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js index b04d3897f..a6cb29d4c 100644 --- a/ui/app/pages/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js +++ b/ui/app/pages/send/send-content/send-amount-row/amount-max-button/tests/amount-max-button-component.test.js @@ -26,7 +26,12 @@ describe('AmountMaxButton Component', function () { setAmountToMax={propsMethodSpies.setAmountToMax} setMaxModeTo={propsMethodSpies.setMaxModeTo} tokenBalance={'mockTokenBalance'} - />, { context: { t: str => str + '_t' } }) + />, { + context: { + t: str => str + '_t', + metricsEvent: () => {}, + }, + }) instance = wrapper.instance() }) diff --git a/ui/app/pages/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js b/ui/app/pages/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js index eecff165d..2013e3200 100644 --- a/ui/app/pages/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js +++ b/ui/app/pages/send/send-content/send-row-wrapper/send-row-error-message/tests/send-row-error-message-container.test.js @@ -5,7 +5,7 @@ let mapStateToProps proxyquire('../send-row-error-message.container.js', { 'react-redux': { - connect: (ms, md) => { + connect: (ms) => { mapStateToProps = ms return () => ({}) }, diff --git a/ui/app/pages/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js b/ui/app/pages/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js index 225bf056c..6c0739f0e 100644 --- a/ui/app/pages/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js +++ b/ui/app/pages/send/send-content/send-row-wrapper/send-row-warning-message/tests/send-row-warning-message-container.test.js @@ -5,7 +5,7 @@ let mapStateToProps proxyquire('../send-row-warning-message.container.js', { 'react-redux': { - connect: (ms, md) => { + connect: (ms) => { mapStateToProps = ms return () => ({}) }, diff --git a/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js b/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js index d0a43f086..b3b0d2da3 100644 --- a/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js +++ b/ui/app/pages/send/send-content/send-to-row/send-to-row.utils.js @@ -10,16 +10,15 @@ import { checkExistingAddresses } from '../../../add-token/util' const ethUtil = require('ethereumjs-util') const contractMap = require('eth-contract-metadata') -function getToErrorObject (to, toError = null, hasHexData = false, tokens = [], selectedToken = null, network) { +function getToErrorObject (to, toError = null, hasHexData = false, _, __, network) { if (!to) { if (!hasHexData) { toError = REQUIRED_ERROR } } else if (!isValidAddress(to, network) && !toError) { toError = isEthNetwork(network) ? INVALID_RECIPIENT_ADDRESS_ERROR : INVALID_RECIPIENT_ADDRESS_NOT_ETH_NETWORK_ERROR - } else if (selectedToken && (ethUtil.toChecksumAddress(to) in contractMap || checkExistingAddresses(to, tokens))) { - toError = KNOWN_RECIPIENT_ADDRESS_ERROR } + return { to: toError } } diff --git a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js b/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js index f29f5efec..f8a6dd96f 100644 --- a/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js +++ b/ui/app/pages/send/send-content/send-to-row/tests/send-to-row-utils.test.js @@ -55,9 +55,9 @@ describe('send-to-row utils', () => { }) }) - it('should return a known address recipient if to is truthy but part of state tokens', () => { + it('should return null if to is truthy but part of state tokens', () => { assert.deepEqual(getToErrorObject('0xabc123', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { - to: KNOWN_RECIPIENT_ADDRESS_ERROR, + to: null, }) }) @@ -67,14 +67,14 @@ describe('send-to-row utils', () => { }) }) - it('should return a known address recipient if to is truthy but part of contract metadata', () => { + it('should return null if to is truthy but part of contract metadata', () => { assert.deepEqual(getToErrorObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { - to: KNOWN_RECIPIENT_ADDRESS_ERROR, + to: null, }) }) it('should null if to is truthy part of contract metadata but selectedToken falsy', () => { assert.deepEqual(getToErrorObject('0x89d24a6b4ccb1b6faa2625fe562bdd9a23260359', undefined, false, [{'address': '0xabc123'}], {'address': '0xabc123'}), { - to: KNOWN_RECIPIENT_ADDRESS_ERROR, + to: null, }) }) }) diff --git a/ui/app/pages/send/tests/send-container.test.js b/ui/app/pages/send/tests/send-container.test.js index b3e202030..131c42f59 100644 --- a/ui/app/pages/send/tests/send-container.test.js +++ b/ui/app/pages/send/tests/send-container.test.js @@ -24,7 +24,7 @@ proxyquire('../send.container.js', { }, }, 'react-router-dom': { withRouter: () => {} }, - 'recompose': { compose: (arg1, arg2) => () => arg2() }, + 'recompose': { compose: (_, arg2) => () => arg2() }, './send.selectors': { getAmountConversionRate: (s) => `mockAmountConversionRate:${s}`, getBlockGasLimit: (s) => `mockBlockGasLimit:${s}`, diff --git a/ui/app/pages/send/tests/send-utils.test.js b/ui/app/pages/send/tests/send-utils.test.js index b19535b9e..bf9cba14a 100644 --- a/ui/app/pages/send/tests/send-utils.test.js +++ b/ui/app/pages/send/tests/send-utils.test.js @@ -17,12 +17,12 @@ const { } = require('../send.constants') const stubs = { - addCurrencies: sinon.stub().callsFake((a, b, obj) => { + addCurrencies: sinon.stub().callsFake((a, b) => { if (String(a).match(/^0x.+/)) a = Number(String(a).slice(2)) if (String(b).match(/^0x.+/)) b = Number(String(b).slice(2)) return a + b }), - conversionUtil: sinon.stub().callsFake((val, obj) => parseInt(val, 16)), + conversionUtil: sinon.stub().callsFake((val) => parseInt(val, 16)), conversionGTE: sinon.stub().callsFake((obj1, obj2) => obj1.value >= obj2.value), multiplyCurrencies: sinon.stub().callsFake((a, b) => `${a}x${b}`), calcTokenAmount: sinon.stub().callsFake((a, d) => 'calc:' + a + d), diff --git a/ui/app/pages/send/to-autocomplete/to-autocomplete.js b/ui/app/pages/send/to-autocomplete/to-autocomplete.js index b246413fb..328a5b62b 100644 --- a/ui/app/pages/send/to-autocomplete/to-autocomplete.js +++ b/ui/app/pages/send/to-autocomplete/to-autocomplete.js @@ -84,7 +84,7 @@ ToAutoComplete.prototype.handleInputEvent = function (event = {}, cb) { cb && cb(event.target.value) } -ToAutoComplete.prototype.componentDidUpdate = function (nextProps, nextState) { +ToAutoComplete.prototype.componentDidUpdate = function (nextProps) { if (this.props.to !== nextProps.to) { this.handleInputEvent() } diff --git a/ui/app/pages/settings/advanced-tab/advanced-tab.component.js b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js index 14b9daae6..3d27fe349 100644 --- a/ui/app/pages/settings/advanced-tab/advanced-tab.component.js +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.component.js @@ -24,6 +24,8 @@ export default class AdvancedTab extends PureComponent { setAdvancedInlineGasFeatureFlag: PropTypes.func, advancedInlineGas: PropTypes.bool, showFiatInTestnets: PropTypes.bool, + autoLogoutTimeLimit: PropTypes.number, + setAutoLogoutTimeLimit: PropTypes.func.isRequired, setShowFiatConversionOnTestnetsPreference: PropTypes.func.isRequired, } @@ -49,7 +51,7 @@ export default class AdvancedTab extends PureComponent { <TextField type="text" id="new-rpc" - placeholder={t('rpcURL')} + placeholder={t('rpcUrl')} value={newRpc} onChange={e => this.setState({ newRpc: e.target.value })} onKeyPress={e => { @@ -355,6 +357,48 @@ export default class AdvancedTab extends PureComponent { ) } + renderAutoLogoutTimeLimit () { + const { t } = this.context + const { + autoLogoutTimeLimit, + setAutoLogoutTimeLimit, + } = this.props + + return ( + <div className="settings-page__content-row"> + <div className="settings-page__content-item"> + <span>{ t('autoLogoutTimeLimit') }</span> + <div className="settings-page__content-description"> + { t('autoLogoutTimeLimitDescription') } + </div> + </div> + <div className="settings-page__content-item"> + <div className="settings-page__content-item-col"> + <TextField + type="number" + id="autoTimeout" + placeholder="5" + value={this.state.autoLogoutTimeLimit} + defaultValue={autoLogoutTimeLimit} + onChange={e => this.setState({ autoLogoutTimeLimit: Math.max(Number(e.target.value), 0) })} + fullWidth + margin="dense" + min={0} + /> + <button + className="button btn-primary settings-tab__rpc-save-button" + onClick={() => { + setAutoLogoutTimeLimit(this.state.autoLogoutTimeLimit) + }} + > + { t('save') } + </button> + </div> + </div> + </div> + ) + } + renderContent () { const { warning } = this.props @@ -368,6 +412,7 @@ export default class AdvancedTab extends PureComponent { { this.renderAdvancedGasInputInline() } { this.renderHexDataOptIn() } { this.renderShowConversionInTestnets() } + { this.renderAutoLogoutTimeLimit() } </div> ) } diff --git a/ui/app/pages/settings/advanced-tab/advanced-tab.container.js b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js index 69d7e07e6..bcac55f5e 100644 --- a/ui/app/pages/settings/advanced-tab/advanced-tab.container.js +++ b/ui/app/pages/settings/advanced-tab/advanced-tab.container.js @@ -8,10 +8,11 @@ import { setFeatureFlag, showModal, setShowFiatConversionOnTestnetsPreference, + setAutoLogoutTimeLimit, } from '../../../store/actions' import {preferencesSelector} from '../../../selectors/selectors' -const mapStateToProps = state => { +export const mapStateToProps = state => { const { appState: { warning }, metamask } = state const { featureFlags: { @@ -19,17 +20,18 @@ const mapStateToProps = state => { advancedInlineGas, } = {}, } = metamask - const { showFiatInTestnets } = preferencesSelector(state) + const { showFiatInTestnets, autoLogoutTimeLimit } = preferencesSelector(state) return { warning, sendHexData, advancedInlineGas, showFiatInTestnets, + autoLogoutTimeLimit, } } -const mapDispatchToProps = dispatch => { +export const mapDispatchToProps = dispatch => { return { setHexDataFeatureFlag: shouldShow => dispatch(setFeatureFlag('sendHexData', shouldShow)), setRpcTarget: (newRpc, chainId, ticker, nickname) => dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname)), @@ -39,6 +41,9 @@ const mapDispatchToProps = dispatch => { setShowFiatConversionOnTestnetsPreference: value => { return dispatch(setShowFiatConversionOnTestnetsPreference(value)) }, + setAutoLogoutTimeLimit: value => { + return dispatch(setAutoLogoutTimeLimit(value)) + }, } } diff --git a/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js new file mode 100644 index 000000000..f81329533 --- /dev/null +++ b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-component.test.js @@ -0,0 +1,44 @@ +import React from 'react' +import assert from 'assert' +import sinon from 'sinon' +import { shallow } from 'enzyme' +import AdvancedTab from '../advanced-tab.component' +import TextField from '../../../../components/ui/text-field' + +describe('AdvancedTab Component', () => { + it('should render correctly', () => { + const root = shallow( + <AdvancedTab />, + { + context: { + t: s => `_${s}`, + }, + } + ) + + assert.equal(root.find('.settings-page__content-row').length, 8) + }) + + it('should update autoLogoutTimeLimit', () => { + const setAutoLogoutTimeLimitSpy = sinon.spy() + const root = shallow( + <AdvancedTab + setAutoLogoutTimeLimit={setAutoLogoutTimeLimitSpy} + />, + { + context: { + t: s => `_${s}`, + }, + } + ) + + const autoTimeout = root.find('.settings-page__content-row').last() + const textField = autoTimeout.find(TextField) + + textField.props().onChange({ target: { value: 1440 } }) + assert.equal(root.state().autoLogoutTimeLimit, 1440) + + autoTimeout.find('button').simulate('click') + assert.equal(setAutoLogoutTimeLimitSpy.args[0][0], 1440) + }) +}) diff --git a/ui/app/pages/settings/advanced-tab/tests/advanced-tab-container.test.js b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-container.test.js new file mode 100644 index 000000000..62122073d --- /dev/null +++ b/ui/app/pages/settings/advanced-tab/tests/advanced-tab-container.test.js @@ -0,0 +1,46 @@ +import assert from 'assert' +import { mapStateToProps, mapDispatchToProps } from '../advanced-tab.container' + +const defaultState = { + appState: { + warning: null, + }, + metamask: { + featureFlags: { + sendHexData: false, + advancedInlineGas: false, + }, + preferences: { + autoLogoutTimeLimit: 0, + showFiatInTestnets: false, + useNativeCurrencyAsPrimaryCurrency: true, + }, + }, +} + +describe('AdvancedTab Container', () => { + it('should map state to props correctly', () => { + const props = mapStateToProps(defaultState) + const expected = { + warning: null, + sendHexData: false, + advancedInlineGas: false, + showFiatInTestnets: false, + autoLogoutTimeLimit: 0, + } + + assert.deepEqual(props, expected) + }) + + it('should map dispatch to props correctly', () => { + const props = mapDispatchToProps(() => 'mockDispatch') + + assert.ok(typeof props.setHexDataFeatureFlag === 'function') + assert.ok(typeof props.setRpcTarget === 'function') + assert.ok(typeof props.displayWarning === 'function') + assert.ok(typeof props.showResetAccountConfirmationModal === 'function') + assert.ok(typeof props.setAdvancedInlineGasFeatureFlag === 'function') + assert.ok(typeof props.setShowFiatConversionOnTestnetsPreference === 'function') + assert.ok(typeof props.setAutoLogoutTimeLimit === 'function') + }) +}) diff --git a/ui/app/pages/settings/index.scss b/ui/app/pages/settings/index.scss index 52208dc85..66959ba93 100644 --- a/ui/app/pages/settings/index.scss +++ b/ui/app/pages/settings/index.scss @@ -1,5 +1,7 @@ @import 'info-tab/index'; +@import 'networks-tab/index'; + @import 'settings-tab/index'; .settings-page { @@ -13,7 +15,6 @@ flex-flow: row nowrap; padding: 12px 24px; align-items: center; - border-bottom: 1px solid $alto; flex: 0 0 auto; &__title { @@ -22,6 +23,45 @@ } } + &__subheader { + padding: 16px 4px; + font-size: 20px; + border-bottom: 1px solid $alto; + margin-right: 24px; + + @media screen and (max-width: 575px) { + display: none; + } + } + + &__sub-header { + height: 72px; + border-bottom: 1px solid #D8D8D8; + display: flex; + justify-content: space-between; + align-items: center; + + @media screen and (max-width: 575px) { + height: 69px; + position: relative; + text-align: center; + } + } + + &__sub-header-text { + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 24px; + line-height: 24px; + color: black; + + @media screen and (max-width: 575px) { + font-size: 16px; + width: 100%; + } + } + &__back-button { display: none; @@ -49,8 +89,9 @@ &__content { display: flex; flex-flow: row nowrap; - height: auto; + height: 100%; overflow: auto; + border-top: 1px solid #D8D8D8; &__tabs { display: flex; @@ -58,9 +99,15 @@ flex: 1 1 auto; @media screen and (min-width: 576px) { - flex: 0 0 32%; + flex: 0 0 40%; max-width: 210px; - border-right: 1px solid $alto; + padding-top: 8px; + } + + .tab-bar__tab { + @media screen and (min-width: 576px) { + padding: 16px 24px 0; + } } } @@ -76,6 +123,10 @@ &__body { padding: 12px 24px; + + @media screen and (min-width: 576px) { + padding: 12px; + } } &__content-row { @@ -89,7 +140,6 @@ min-width: 0; display: flex; flex-direction: column; - padding: 0 5px; min-height: 71px; @media screen and (max-width: 575px) { diff --git a/ui/app/pages/settings/networks-tab/index.js b/ui/app/pages/settings/networks-tab/index.js new file mode 100644 index 000000000..362004498 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/index.js @@ -0,0 +1 @@ +export { default } from './networks-tab.container' diff --git a/ui/app/pages/settings/networks-tab/index.scss b/ui/app/pages/settings/networks-tab/index.scss new file mode 100644 index 000000000..b0020437d --- /dev/null +++ b/ui/app/pages/settings/networks-tab/index.scss @@ -0,0 +1,200 @@ +.networks-tab { + &__content { + margin-top: 24px; + display: flex; + height: 100%; + max-width: 739px; + justify-content: space-between; + + @media screen and (max-width: 575px) { + margin-top: 0px; + } + } + + &__body { + padding: 12px 24px; + height: 100%; + display: flex; + flex-direction: column; + + @media screen and (max-width: 575px) { + padding: 0; + } + } + + &__back-button { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-left-black.svg'); + width: 18px; + height: 18px; + opacity: .5; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + margin-right: 16px; + cursor: pointer; + position: absolute; + margin-left: 10px; + } + } + + &__network-form { + flex: 0.5 0 auto; + max-width: 343px; + max-height: 465px; + display: flex; + flex-direction: column; + justify-content: space-between; + + .page-container__footer { + border-top: none; + + @media screen and (max-width: 575px) { + width: 93%; + } + + header { + padding: 10px 0px; + } + } + + @media screen and (max-width: 575px) { + display: flex; + flex: auto; + max-width: 100%; + max-height: 100%; + align-items: center; + width: 100%; + margin-top: 10px; + } + } + + &__network-form-row { + @media screen and (max-width: 575px) { + display: flex; + flex-direction: column; + width: 93%; + } + } + + &__network-form-label { + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 14px; + line-height: 20px; + color: #000000; + } + + &__networks-list { + flex: 0.5 0 auto; + max-width: 343px; + + @media screen and (max-width: 575px) { + max-width: 100vw; + width: 100vw; + overflow-y: scroll; + } + } + + &__add-network-button-wrapper { + display: none; + + @media screen and (max-width: 575px) { + display: flex; + padding-top: 19px; + padding-bottom: 23px; + justify-content: center; + align-items: center; + border-top: 1px solid #D8D8D8; + + .button { + width: 178px; + } + } + } + + &__add-network-header-button-wrapper { + padding-top: 15px; + padding-bottom: 21px; + justify-content: center; + + .button { + width: 178px; + } + + @media screen and (max-width: 575px) { + display: none; + } + } + + &__networks-list--selection { + @media screen and (max-width: 575px) { + display: none; + } + } + + &__networks-list-item { + display: flex; + padding: 13px 0px 13px 17px; + position: relative; + + .menu-icon-circle { + &:hover { + cursor: pointer; + } + } + + @media screen and (max-width: 575px) { + padding: 20px 23px 21px 17px; + border-bottom: 1px solid #D8D8D8; + } + } + + &__networks-list-item:last-of-type { + @media screen and (max-width: 575px) { + border-bottom: none; + } + } + + &__networks-list-name { + margin-left: 11px; + font-family: Roboto; + font-style: normal; + font-weight: normal; + font-size: 16px; + line-height: 23px; + color: #6A737D; + + &:hover { + cursor: pointer; + } + } + + &__networks-list-arrow { + display: none; + + @media screen and (max-width: 575px) { + display: block; + background-image: url('/images/caret-right.svg'); + width: 24px; + height: 24px; + background-size: contain; + background-repeat: no-repeat; + background-position: center; + right: 10px; + cursor: pointer; + position: absolute; + width: 24px; + height: 24px; + } + } + + &__networks-list-name--selected { + font-weight: bold; + color: #000000; + } +}
\ No newline at end of file diff --git a/ui/app/pages/settings/networks-tab/network-form/index.js b/ui/app/pages/settings/networks-tab/network-form/index.js new file mode 100644 index 000000000..89d9de42b --- /dev/null +++ b/ui/app/pages/settings/networks-tab/network-form/index.js @@ -0,0 +1 @@ +export { default } from './network-form.component' diff --git a/ui/app/pages/settings/networks-tab/network-form/network-form.component.js b/ui/app/pages/settings/networks-tab/network-form/network-form.component.js new file mode 100644 index 000000000..5e455b65e --- /dev/null +++ b/ui/app/pages/settings/networks-tab/network-form/network-form.component.js @@ -0,0 +1,225 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import validUrl from 'valid-url' +import PageContainerFooter from '../../../../components/ui/page-container/page-container-footer' +import TextField from '../../../../components/ui/text-field' + +export default class NetworksTab extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + metricsEvent: PropTypes.func.isRequired, + } + + static propTypes = { + editRpc: PropTypes.func.isRequired, + rpcUrl: PropTypes.string, + chainId: PropTypes.string, + ticker: PropTypes.string, + viewOnly: PropTypes.bool, + networkName: PropTypes.string, + onClear: PropTypes.func.isRequired, + setRpcTarget: PropTypes.func.isRequired, + networksTabIsInAddMode: PropTypes.bool, + blockExplorerUrl: PropTypes.string, + rpcPrefs: PropTypes.object, + } + + state = { + rpcUrl: this.props.rpcUrl, + chainId: this.props.chainId, + ticker: this.props.ticker, + networkName: this.props.networkName, + blockExplorerUrl: this.props.blockExplorerUrl, + errors: {}, + } + + componentDidUpdate (prevProps) { + const { rpcUrl: prevRpcUrl, networksTabIsInAddMode: prevAddMode } = prevProps + const { + rpcUrl, + chainId, + ticker, + networkName, + networksTabIsInAddMode, + blockExplorerUrl, + } = this.props + + if (!prevAddMode && networksTabIsInAddMode) { + this.setState({ + rpcUrl: '', + chainId: '', + ticker: '', + networkName: '', + blockExplorerUrl: '', + errors: {}, + }) + } else if (prevRpcUrl !== rpcUrl) { + this.setState({ rpcUrl, chainId, ticker, networkName, blockExplorerUrl, errors: {} }) + } + } + + componentWillUnmount () { + this.props.onClear() + this.setState({ + rpcUrl: '', + chainId: '', + ticker: '', + networkName: '', + blockExplorerUrl: '', + errors: {}, + }) + } + + stateIsUnchanged () { + const { + rpcUrl, + chainId, + ticker, + networkName, + blockExplorerUrl, + } = this.props + + const { + rpcUrl: stateRpcUrl, + chainId: stateChainId, + ticker: stateTicker, + networkName: stateNetworkName, + blockExplorerUrl: stateBlockExplorerUrl, + } = this.state + + return ( + stateRpcUrl === rpcUrl && + stateChainId === chainId && + stateTicker === ticker && + stateNetworkName === networkName && + stateBlockExplorerUrl === blockExplorerUrl + ) + } + + renderFormTextField (fieldKey, textFieldId, onChange, value, optionalTextFieldKey) { + const { errors } = this.state + const { viewOnly } = this.props + + return ( + <div className="networks-tab__network-form-row"> + <div className="networks-tab__network-form-label">{this.context.t(optionalTextFieldKey || fieldKey)}</div> + <TextField + type="text" + id={textFieldId} + onChange={onChange} + fullWidth + margin="dense" + value={value} + disabled={viewOnly} + error={errors[fieldKey]} + /> + </div> + ) + } + + setStateWithValue = (stateKey, validator) => { + return (e) => { + validator && validator(e.target.value, stateKey) + this.setState({ [stateKey]: e.target.value }) + } + } + + setErrorTo = (errorKey, errorVal) => { + this.setState({ + errors: { + ...this.state.errors, + [errorKey]: errorVal, + }, + }) + } + + validateChainId = (chainId) => { + this.setErrorTo('chainId', !!chainId && Number.isNaN(parseInt(chainId)) + ? `${this.context.t('invalidInput')} chainId` + : '' + ) + } + + validateUrl = (url, stateKey) => { + if (validUrl.isWebUri(url)) { + this.setErrorTo(stateKey, '') + } else { + const appendedRpc = `http://${url}` + const validWhenAppended = validUrl.isWebUri(appendedRpc) && !url.match(/^https?:\/\/$/) + + this.setErrorTo(stateKey, this.context.t(validWhenAppended ? 'uriErrorMsg' : 'invalidRPC')) + } + } + + render () { + const { setRpcTarget, viewOnly, rpcUrl: propsRpcUrl, editRpc, rpcPrefs = {} } = this.props + const { + networkName, + rpcUrl, + chainId, + ticker, + blockExplorerUrl, + errors, + } = this.state + + + return ( + <div className="networks-tab__network-form"> + {this.renderFormTextField( + 'networkName', + 'network-name', + this.setStateWithValue('networkName'), + networkName, + )} + {this.renderFormTextField( + 'rpcUrl', + 'rpc-url', + this.setStateWithValue('rpcUrl', this.validateUrl), + rpcUrl, + )} + {this.renderFormTextField( + 'chainId', + 'chainId', + this.setStateWithValue('chainId', this.validateChainId), + chainId, + 'optionalChainId', + )} + {this.renderFormTextField( + 'symbol', + 'network-ticker', + this.setStateWithValue('ticker'), + ticker, + 'optionalSymbol', + )} + {this.renderFormTextField( + 'blockExplorerUrl', + 'block-explorer-url', + this.setStateWithValue('blockExplorerUrl', this.validateUrl), + blockExplorerUrl, + 'optionalBlockExplorerUrl', + )} + <PageContainerFooter + cancelText={this.context.t('cancel')} + hideCancel={true} + onSubmit={() => { + if (propsRpcUrl && rpcUrl !== propsRpcUrl) { + editRpc(propsRpcUrl, rpcUrl, chainId, ticker, networkName, { + blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, + ...rpcPrefs, + }) + } else { + setRpcTarget(rpcUrl, chainId, ticker, networkName, { + blockExplorerUrl: blockExplorerUrl || rpcPrefs.blockExplorerUrl, + ...rpcPrefs, + }) + } + }} + submitText={this.context.t('save')} + submitButtonType={'confirm'} + disabled={viewOnly || this.stateIsUnchanged() || Object.values(errors).some(x => x) || !rpcUrl} + /> + </div> + ) + } + +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.component.js b/ui/app/pages/settings/networks-tab/networks-tab.component.js new file mode 100644 index 000000000..2f921a892 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.component.js @@ -0,0 +1,214 @@ +import React, { PureComponent } from 'react' +import PropTypes from 'prop-types' +import { SETTINGS_ROUTE } from '../../../helpers/constants/routes' +import { ENVIRONMENT_TYPE_POPUP } from '../../../../../app/scripts/lib/enums' +import { getEnvironmentType } from '../../../../../app/scripts/lib/util' +import classnames from 'classnames' +import Button from '../../../components/ui/button' +import NetworkForm from './network-form' +import NetworkDropdownIcon from '../../../components/app/dropdowns/components/network-dropdown-icon' + +export default class NetworksTab extends PureComponent { + static contextTypes = { + t: PropTypes.func.isRequired, + metricsEvent: PropTypes.func.isRequired, + } + + static propTypes = { + editRpc: PropTypes.func.isRequired, + history: PropTypes.object.isRequired, + location: PropTypes.object.isRequired, + networkIsSelected: PropTypes.bool, + networksTabIsInAddMode: PropTypes.bool, + networksToRender: PropTypes.array.isRequired, + selectedNetwork: PropTypes.object, + setNetworksTabAddMode: PropTypes.func.isRequired, + setRpcTarget: PropTypes.func.isRequired, + setSelectedSettingsRpcUrl: PropTypes.func.isRequired, + providerUrl: PropTypes.string, + providerType: PropTypes.string, + networkDefaultedToProvider: PropTypes.bool, + } + + componentWillMount () { + this.props.setSelectedSettingsRpcUrl(null) + } + + isCurrentPath (pathname) { + return this.props.location.pathname === pathname + } + + renderSubHeader () { + const { + networkIsSelected, + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + networksTabIsInAddMode, + networkDefaultedToProvider, + } = this.props + + return ( + <div className="settings-page__sub-header"> + <div + className="networks-tab__back-button" + onClick={(networkIsSelected && !networkDefaultedToProvider) || networksTabIsInAddMode + ? () => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(null) + } + : () => this.props.history.push(SETTINGS_ROUTE) + } + /> + <span className="settings-page__sub-header-text">{ this.context.t('networks') }</span> + <div className="networks-tab__add-network-header-button-wrapper"> + <Button + type="primary" + onClick={event => { + event.preventDefault() + setSelectedSettingsRpcUrl(null) + setNetworksTabAddMode(true) + }} + > + { this.context.t('addNetwork') } + </Button> + </div> + </div> + ) + } + + renderNetworkListItem (network, selectRpcUrl) { + const { + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + networkIsSelected, + providerUrl, + providerType, + networksTabIsInAddMode, + } = this.props + const { + border, + iconColor, + label, + labelKey, + rpcUrl, + providerType: currentProviderType, + } = network + + const listItemNetworkIsSelected = selectRpcUrl && selectRpcUrl === rpcUrl + const listItemUrlIsProviderUrl = rpcUrl === providerUrl + const listItemTypeIsProviderNonRpcType = providerType !== 'rpc' && currentProviderType === providerType + const listItemNetworkIsCurrentProvider = !networkIsSelected && !networksTabIsInAddMode && (listItemUrlIsProviderUrl || listItemTypeIsProviderNonRpcType) + const displayNetworkListItemAsSelected = listItemNetworkIsSelected || listItemNetworkIsCurrentProvider + + return ( + <div + key={'settings-network-list-item:' + rpcUrl} + className="networks-tab__networks-list-item" + onClick={ () => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(rpcUrl) + }} + > + <NetworkDropdownIcon + backgroundColor={iconColor || 'white'} + innerBorder={border} + /> + <div className={ classnames('networks-tab__networks-list-name', { + 'networks-tab__networks-list-name--selected': displayNetworkListItemAsSelected, + }) }> + { label || this.context.t(labelKey) } + </div> + <div className="networks-tab__networks-list-arrow" /> + </div> + ) + } + + renderNetworksList () { + const { networksToRender, selectedNetwork, networkIsSelected, networksTabIsInAddMode, networkDefaultedToProvider } = this.props + + return ( + <div className={classnames('networks-tab__networks-list', { + 'networks-tab__networks-list--selection': (networkIsSelected && !networkDefaultedToProvider) || networksTabIsInAddMode, + })}> + { networksToRender.map(network => this.renderNetworkListItem(network, selectedNetwork.rpcUrl)) } + </div> + ) + } + + renderNetworksTabContent () { + const { + setRpcTarget, + setSelectedSettingsRpcUrl, + setNetworksTabAddMode, + selectedNetwork: { + labelKey, + label, + rpcUrl, + chainId, + ticker, + viewOnly, + rpcPrefs, + blockExplorerUrl, + }, + networksTabIsInAddMode, + editRpc, + networkDefaultedToProvider, + } = this.props + const envIsPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP + + return ( + <div className="networks-tab__content"> + {this.renderNetworksList()} + {networksTabIsInAddMode || !envIsPopup || (envIsPopup && !networkDefaultedToProvider) + ? <NetworkForm + setRpcTarget={setRpcTarget} + editRpc={editRpc} + networkName={label || labelKey && this.context.t(labelKey) || ''} + rpcUrl={rpcUrl} + chainId={chainId} + ticker={ticker} + onClear={() => { + setNetworksTabAddMode(false) + setSelectedSettingsRpcUrl(null) + }} + viewOnly={viewOnly} + networksTabIsInAddMode={networksTabIsInAddMode} + rpcPrefs={rpcPrefs} + blockExplorerUrl={blockExplorerUrl} + /> + : null + } + </div> + ) + } + + renderContent () { + const { setNetworksTabAddMode, setSelectedSettingsRpcUrl, networkIsSelected, networksTabIsInAddMode } = this.props + + return ( + <div className="networks-tab__body"> + {this.renderSubHeader()} + {this.renderNetworksTabContent()} + {!networkIsSelected && !networksTabIsInAddMode + ? <div className="networks-tab__add-network-button-wrapper"> + <Button + type="primary" + onClick={event => { + event.preventDefault() + setSelectedSettingsRpcUrl(null) + setNetworksTabAddMode(true) + }} + > + { this.context.t('addNetwork') } + </Button> + </div> + : null + } + </div> + ) + } + + render () { + return this.renderContent() + } +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.constants.js b/ui/app/pages/settings/networks-tab/networks-tab.constants.js new file mode 100644 index 000000000..d3d1a01cc --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.constants.js @@ -0,0 +1,50 @@ +const defaultNetworksData = [ + { + labelKey: 'mainnet', + iconColor: '#29B6AF', + providerType: 'mainnet', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/mainnet', + chainId: '1', + ticker: 'ETH', + blockExplorerUrl: 'https://etherscan.io', + }, + { + labelKey: 'ropsten', + iconColor: '#FF4A8D', + providerType: 'ropsten', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/ropsten', + chainId: '3', + ticker: 'ETH', + blockExplorerUrl: 'https://ropsten.etherscan.io', + }, + { + labelKey: 'kovan', + iconColor: '#9064FF', + providerType: 'kovan', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/kovan', + chainId: '4', + ticker: 'ETH', + blockExplorerUrl: 'https://etherscan.io', + }, + { + labelKey: 'rinkeby', + iconColor: '#F6C343', + providerType: 'rinkeby', + rpcUrl: 'https://api.infura.io/v1/jsonrpc/rinkeby', + chainId: '42', + ticker: 'ETH', + blockExplorerUrl: 'https://rinkeby.etherscan.io', + }, + { + labelKey: 'localhost', + iconColor: 'white', + border: '1px solid #6A737D', + providerType: 'localhost', + rpcUrl: 'http://localhost:8545/', + blockExplorerUrl: 'https://etherscan.io', + }, +] + +export { + defaultNetworksData, +} diff --git a/ui/app/pages/settings/networks-tab/networks-tab.container.js b/ui/app/pages/settings/networks-tab/networks-tab.container.js new file mode 100644 index 000000000..a5d71f714 --- /dev/null +++ b/ui/app/pages/settings/networks-tab/networks-tab.container.js @@ -0,0 +1,77 @@ +import NetworksTab from './networks-tab.component' +import { compose } from 'recompose' +import { connect } from 'react-redux' +import { withRouter } from 'react-router-dom' +import { + setSelectedSettingsRpcUrl, + updateAndSetCustomRpc, + displayWarning, + setNetworksTabAddMode, + editRpc, +} from '../../../store/actions' +import { defaultNetworksData } from './networks-tab.constants' +const defaultNetworks = defaultNetworksData.map(network => ({ ...network, viewOnly: true })) + +const mapStateToProps = state => { + const { + frequentRpcListDetail, + provider, + } = state.metamask + const { + networksTabSelectedRpcUrl, + networksTabIsInAddMode, + } = state.appState + + const frequentRpcNetworkListDetails = frequentRpcListDetail.map(rpc => { + return { + label: rpc.nickname, + iconColor: '#6A737D', + providerType: 'rpc', + rpcUrl: rpc.rpcUrl, + chainId: rpc.chainId, + ticker: rpc.ticker, + blockExplorerUrl: rpc.rpcPrefs && rpc.rpcPrefs.blockExplorerUrl || '', + } + }) + + const networksToRender = [ ...defaultNetworks, ...frequentRpcNetworkListDetails ] + let selectedNetwork = networksToRender.find(network => network.rpcUrl === networksTabSelectedRpcUrl) || {} + const networkIsSelected = Boolean(selectedNetwork.rpcUrl) + + let networkDefaultedToProvider = false + if (!networkIsSelected && !networksTabIsInAddMode) { + selectedNetwork = networksToRender.find(network => { + return network.rpcUrl === provider.rpcTarget || network.providerType !== 'rpc' && network.providerType === provider.type + }) || {} + networkDefaultedToProvider = true + } + + return { + selectedNetwork, + networksToRender, + networkIsSelected, + networksTabIsInAddMode, + providerType: provider.type, + providerUrl: provider.rpcTarget, + networkDefaultedToProvider, + } +} + +const mapDispatchToProps = dispatch => { + return { + setSelectedSettingsRpcUrl: newRpcUrl => dispatch(setSelectedSettingsRpcUrl(newRpcUrl)), + setRpcTarget: (newRpc, chainId, ticker, nickname, rpcPrefs) => { + dispatch(updateAndSetCustomRpc(newRpc, chainId, ticker, nickname, rpcPrefs)) + }, + displayWarning: warning => dispatch(displayWarning(warning)), + setNetworksTabAddMode: isInAddMode => dispatch(setNetworksTabAddMode(isInAddMode)), + editRpc: (oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs) => { + dispatch(editRpc(oldRpc, newRpc, chainId, ticker, nickname, rpcPrefs)) + }, + } +} + +export default compose( + withRouter, + connect(mapStateToProps, mapDispatchToProps) +)(NetworksTab) diff --git a/ui/app/pages/settings/settings.component.js b/ui/app/pages/settings/settings.component.js index 061e65060..a2f137264 100644 --- a/ui/app/pages/settings/settings.component.js +++ b/ui/app/pages/settings/settings.component.js @@ -1,11 +1,12 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' -import { Switch, Route, matchPath } from 'react-router-dom' +import { Switch, Route, matchPath, withRouter } from 'react-router-dom' import { ENVIRONMENT_TYPE_POPUP } from '../../../../app/scripts/lib/enums' import { getEnvironmentType } from '../../../../app/scripts/lib/util' import TabBar from '../../components/app/tab-bar' import c from 'classnames' import SettingsTab from './settings-tab' +import NetworksTab from './networks-tab' import AdvancedTab from './advanced-tab' import InfoTab from './info-tab' import SecurityTab from './security-tab' @@ -16,6 +17,7 @@ import { GENERAL_ROUTE, ABOUT_US_ROUTE, SETTINGS_ROUTE, + NETWORKS_ROUTE, } from '../../helpers/constants/routes' const ROUTES_TO_I18N_KEYS = { @@ -25,7 +27,7 @@ const ROUTES_TO_I18N_KEYS = { [ABOUT_US_ROUTE]: 'about', } -export default class SettingsPage extends PureComponent { +class SettingsPage extends PureComponent { static propTypes = { location: PropTypes.object, history: PropTypes.object, @@ -55,7 +57,7 @@ export default class SettingsPage extends PureComponent { > <div className="settings-page__header"> { - !this.isCurrentPath(SETTINGS_ROUTE) && ( + !this.isCurrentPath(SETTINGS_ROUTE) && !this.isCurrentPath(NETWORKS_ROUTE) && ( <div className="settings-page__back-button" onClick={() => history.push(SETTINGS_ROUTE)} @@ -75,6 +77,7 @@ export default class SettingsPage extends PureComponent { { this.renderTabs() } </div> <div className="settings-page__content__modules"> + { this.renderSubHeader() } { this.renderContent() } </div> </div> @@ -82,6 +85,17 @@ export default class SettingsPage extends PureComponent { ) } + renderSubHeader () { + const { t } = this.context + const { location: { pathname } } = this.props + + return ( + <div className="settings-page__subheader"> + {t(ROUTES_TO_I18N_KEYS[pathname] || 'general')} + </div> + ) + } + renderTabs () { const { history, location } = this.props const { t } = this.context @@ -92,6 +106,7 @@ export default class SettingsPage extends PureComponent { { content: t('general'), description: t('generalSettingsDescription'), key: GENERAL_ROUTE }, { content: t('advanced'), description: t('advancedSettingsDescription'), key: ADVANCED_ROUTE }, { content: t('securityAndPrivacy'), description: t('securitySettingsDescription'), key: SECURITY_ROUTE }, + { content: t('networks'), description: t('networkSettingsDescription'), key: NETWORKS_ROUTE }, { content: t('about'), description: t('aboutSettingsDescription'), key: ABOUT_US_ROUTE }, ]} isActive={key => { @@ -125,6 +140,11 @@ export default class SettingsPage extends PureComponent { /> <Route exact + path={NETWORKS_ROUTE} + component={NetworksTab} + /> + <Route + exact path={SECURITY_ROUTE} component={SecurityTab} /> @@ -135,3 +155,5 @@ export default class SettingsPage extends PureComponent { ) } } + +export default withRouter(SettingsPage) |