diff --git a/.eslintrc b/.eslintrc index bbe3a8a7..7c00e41a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,11 +4,15 @@ "plugin:flowtype/recommended", "plugin:jest/recommended" ], + "globals": { + "COMMITHASH": true + }, "env": { "browser": true, "jest": true }, "rules": { + "no-use-before-define": 0, "no-plusplus": 0, "class-methods-use-this": 0, "react/require-default-props": 0, diff --git a/jest.config.js b/jest.config.js index d7e1c86a..d9c12451 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,11 +1,19 @@ module.exports = { rootDir: './src', + automock: false, + coverageDirectory: 'coverage/', collectCoverage: true, testURL: 'http://localhost', modulePathIgnorePatterns: [ 'node_modules', + 'utils/windowUtils.js', + 'utils/promiseUtils.js', + 'utils/networkUtils.js', ], collectCoverageFrom: [ 'utils/**.js', ], + setupFiles: [ + './support/setupJest.js', + ], }; diff --git a/package.json b/package.json index d2960e9b..2f795f89 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,12 @@ "ethereumjs-tx": "^1.3.3", "ethereumjs-units": "^0.2.0", "ethereumjs-util": "^5.1.4", + "git-revision-webpack-plugin": "^3.0.3", + "flow-webpack-plugin": "^1.2.0", "hdkey": "^0.8.0", "html-webpack-plugin": "^3.2.0", "http-server": "^0.11.1", + "jest-fetch-mock": "^1.6.5", "npm-run-all": "^4.1.3", "prop-types": "^15.6.2", "raf": "^3.4.0", @@ -51,8 +54,7 @@ "react-router-redux": "next", "react-scale-text": "^1.2.2", "react-select": "2.0.0", - "react-sticky-el": "^1.0.20", - "react-transition-group": "^2.2.1", + "react-transition-group": "^2.4.0", "redbox-react": "^1.6.0", "redux": "4.0.0", "redux-logger": "^3.0.6", diff --git a/public/data/appConfig.json b/public/data/appConfig.json index c9c3ca0c..93b1520b 100644 --- a/public/data/appConfig.json +++ b/public/data/appConfig.json @@ -58,11 +58,11 @@ "fiatValueTickers": [ { - "network": "ethereum", + "network": "eth", "url": "https://api.coinmarketcap.com/v1/ticker/ethereum/" }, { - "network": "ethereum-classic", + "network": "etc", "url": "https://api.coinmarketcap.com/v1/ticker/ethereum-classic/" } ], diff --git a/src/actions/BlockchainActions.js b/src/actions/BlockchainActions.js index 353cd345..5414df9d 100644 --- a/src/actions/BlockchainActions.js +++ b/src/actions/BlockchainActions.js @@ -111,7 +111,7 @@ export const onBlockMined = (coinInfo: any): PromiseAction => async (dispa dispatch( Web3Actions.updateAccount(accounts[i], a, network) ) } else { // there are no new txs, just update block - dispatch( AccountsActions.update( { ...accounts[i], ...a }) ); + dispatch( AccountsActions.update( { ...accounts[i], block: a.block }) ); // HACK: since blockbook can't work with smart contracts for now // try to update tokens balances added to this account using Web3 diff --git a/src/actions/LocalStorageActions.js b/src/actions/LocalStorageActions.js index ae7f7858..5e6cef06 100644 --- a/src/actions/LocalStorageActions.js +++ b/src/actions/LocalStorageActions.js @@ -21,7 +21,7 @@ export type StorageAction = { type: typeof STORAGE.READY, config: Config, tokens: TokensCollection, - ERC20Abi: Array + ERC20Abi: Array } | { type: typeof STORAGE.SAVE, network: string, @@ -148,7 +148,6 @@ export function loadTokensFromJSON(): AsyncAction { }); } - dispatch({ type: STORAGE.READY, config, diff --git a/src/actions/RouterActions.js b/src/actions/RouterActions.js new file mode 100644 index 00000000..afa68ebe --- /dev/null +++ b/src/actions/RouterActions.js @@ -0,0 +1,304 @@ +/* @flow */ + +import { push, LOCATION_CHANGE } from 'react-router-redux'; +import { routes } from 'support/routes'; + +import type { + RouterLocationState, + Device, + TrezorDevice, + ThunkAction, + PayloadAction, + Dispatch, + GetState, +} from 'flowtype'; +import type { RouterAction } from 'react-router-redux'; + +/* +* Parse url string to RouterLocationState object (key/value) +*/ +export const pathToParams = (path: string): PayloadAction => (): RouterLocationState => { + // split url into parts + const parts: Array = path.split('/').slice(1); + const params: RouterLocationState = {}; + // return empty params + if (parts.length < 1 || path === '/') return params; + + // map parts to params by key/value + // assuming that url is in format: "/key/value" + for (let i = 0, len = parts.length; i < len; i += 2) { + params[parts[i]] = parts[i + 1] || parts[i]; + } + + // check for special case: /device/device-id:instance-id + if (params.hasOwnProperty('device')) { + const isClonedDevice: Array = params.device.split(':'); + if (isClonedDevice.length > 1) { + const [device, instance] = isClonedDevice; + params.device = device; + params.deviceInstance = instance; + } + } + return params; +}; + +/* +* RouterLocationState validation +* Check if requested device or network exists in reducers +*/ +export const paramsValidation = (params: RouterLocationState): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + // validate requested device + if (params.hasOwnProperty('device')) { + const { devices } = getState(); + + let device: ?TrezorDevice; + if (params.hasOwnProperty('deviceInstance')) { + device = devices.find(d => d.features && d.features.device_id === params.device && d.instance === parseInt(params.deviceInstance, 10)); + } else { + device = devices.find(d => d.path === params.device || (d.features && d.features.device_id === params.device)); + } + + if (!device) return false; + } + + // validate requested network + if (params.hasOwnProperty('network')) { + const { config } = getState().localStorage; + const coin = config.coins.find(c => c.network === params.network); + if (!coin) return false; + if (!params.account) return false; + } + + // validate requested account + // TODO: only if discovery on this network is completed + // if (params.hasOwnProperty('account')) { + + // } + return true; +}; + +/* +* Composing url string from given RouterLocationState object +* Filters unrecognized fields and sorting in correct order +*/ +export const paramsToPath = (params: RouterLocationState): PayloadAction => (): ?string => { + // get patterns (fields) from routes and sort them by complexity + const patterns: Array> = routes.map(r => r.fields).sort((a, b) => (a.length > b.length ? -1 : 1)); + + // find pattern + const keys: Array = Object.keys(params); + let patternToUse: ?Array; + let i: number; + for (i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + const match: Array = keys.filter(key => pattern.indexOf(key) >= 0); + if (match.length === pattern.length) { + patternToUse = pattern; + break; + } + } + + // pattern not found, redirect back + if (!patternToUse) return null; + + // compose url string from pattern + let url: string = ''; + patternToUse.forEach((field) => { + if (field === params[field]) { + // standalone (odd) fields + url += `/${field}`; + } else { + url += `/${field}/${params[field]}`; + if (field === 'device') { + if (params.hasOwnProperty('deviceInstance')) { + url += `:${params.deviceInstance}`; + } + } + } + }); + return url; +}; + +export const getValidUrl = (action: RouterAction): PayloadAction => (dispatch: Dispatch, getState: GetState): string => { + const { location } = getState().router; + + // redirect to landing page (loading screen) + // and wait until application is ready + if (!location) return '/'; + + const requestedUrl = action.payload.pathname; + // Corner case: LOCATION_CHANGE was called but pathname didn't changed (redirect action from RouterService) + if (requestedUrl === location.pathname) return requestedUrl; + + // Modal is opened + // redirect to previous url + if (getState().modal.opened) { + // Corner case: modal is opened and currentParams are still valid + // example 1 (valid blocking): url changed while passphrase modal opened but device is still connected (we want user to finish this action) + // example 2 (invalid blocking): url changes while passphrase modal opened because device disconnect + const currentParams = dispatch(pathToParams(location.pathname)); + const currentParamsAreValid = dispatch(paramsValidation(currentParams)); + if (currentParamsAreValid) { return location.pathname; } + } + + // there are no connected devices or application isn't ready or initialization error occurred + // redirect to landing page + const shouldBeLandingPage = getState().devices.length < 1 || !getState().wallet.ready || getState().connect.error !== null; + const landingPageUrl = dispatch(isLandingPageUrl(requestedUrl)); + if (shouldBeLandingPage) { + return !landingPageUrl ? '/' : requestedUrl; + } + + // Disallow displaying landing page + // redirect to previous url + if (!shouldBeLandingPage && landingPageUrl) { + return location.pathname; + } + + // Regular url change during application live cycle + const requestedParams = dispatch(pathToParams(requestedUrl)); + const requestedParamsAreValid: boolean = dispatch(paramsValidation(requestedParams)); + + // Requested params are not valid + // Neither device or network doesn't exists + if (!requestedParamsAreValid) { + return location.pathname; + } + + // Compose valid url from requested params + const composedUrl = dispatch(paramsToPath(requestedParams)); + return composedUrl || location.pathname; +}; + + +/* +* Utility used in "selectDevice" and "selectFirstAvailableDevice" +* sorting device array by "ts" (timestamp) field +*/ +const sortDevices = (devices: Array): Array => devices.sort((a, b) => { + if (!a.ts || !b.ts) { + return -1; + } + return a.ts > b.ts ? -1 : 1; +}); + +/* +* Compose url from given device object and redirect +*/ +export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + let url: ?string; + if (!device.features) { + url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`; + } else if (device.features.bootloader_mode) { + url = `/device/${device.path}/bootloader`; + } else if (!device.features.initialized) { + url = `/device/${device.features.device_id}/initialize`; + } else if (typeof device.instance === 'number') { + url = `/device/${device.features.device_id}:${device.instance}`; + } else { + url = `/device/${device.features.device_id}`; + // make sure that device is not TrezorDevice type + if (!device.hasOwnProperty('ts')) { + // it is device from trezor-connect triggered by DEVICE.CONNECT event + // need to lookup if there are unavailable instances + const available: Array = getState().devices.filter(d => d.path === device.path); + const latest: Array = sortDevices(available); + if (latest.length > 0 && latest[0].instance) { + url += `:${latest[0].instance}`; + } + } + } + + const currentParams: RouterLocationState = getState().router.location.state; + const requestedParams = dispatch(pathToParams(url)); + if (currentParams.device !== requestedParams.device || currentParams.deviceInstance !== requestedParams.deviceInstance) { + dispatch(goto(url)); + } +}; + +/* +* Try to find first available device using order: +* 1. First unacquired +* 2. First connected +* 3. Saved with latest timestamp +* OR redirect to landing page +*/ +export const selectFirstAvailableDevice = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { devices } = getState(); + if (devices.length > 0) { + const unacquired = devices.find(d => !d.features); + if (unacquired) { + dispatch(selectDevice(unacquired)); + } else { + const latest: Array = sortDevices(devices); + const firstConnected: ?TrezorDevice = latest.find(d => d.connected); + dispatch(selectDevice(firstConnected || latest[0])); + } + } else { + dispatch(gotoLandingPage()); + } +}; + +/* +* Internal method. redirect to given url +*/ +const goto = (url: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + if (getState().router.location.pathname !== url) { + dispatch(push(url)); + } +}; + +/* +* Check if requested OR current url is landing page +*/ +export const isLandingPageUrl = ($url?: string): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + let url: ?string = $url; + if (typeof url !== 'string') { + url = getState().router.location.pathname; + } + // TODO: add more landing page cases/urls to config.json (like /tools etc) + return (url === '/' || url === '/bridge'); +}; + +/* +* Try to redirect to landing page +*/ +export const gotoLandingPage = (): ThunkAction => (dispatch: Dispatch): void => { + const isLandingPage = dispatch(isLandingPageUrl()); + if (!isLandingPage) { + dispatch(goto('/')); + } +}; + +/* +* Go to given device settings page +*/ +export const gotoDeviceSettings = (device: TrezorDevice): ThunkAction => (dispatch: Dispatch): void => { + if (device.features) { + const devUrl: string = `${device.features.device_id}${device.instance ? `:${device.instance}` : ''}`; + dispatch(goto(`/device/${devUrl}/settings`)); + } +}; + +/* +* Try to redirect to initial url +*/ +export const setInitialUrl = (): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + const { initialPathname } = getState().wallet; + if (typeof initialPathname === 'string' && !dispatch(isLandingPageUrl(initialPathname))) { + const valid = dispatch(getValidUrl({ + type: LOCATION_CHANGE, + payload: { + pathname: initialPathname, + hash: '', + search: '', + state: {}, + }, + })); + if (valid === initialPathname) { + dispatch(goto(valid)); + return true; + } + } + return false; +}; diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index 0bacf1b3..dd54a365 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -7,6 +7,9 @@ import * as SEND from 'actions/constants/send'; import * as NOTIFICATION from 'actions/constants/notification'; import * as PENDING from 'actions/constants/pendingTx'; +import * as SendFormActions from 'actions/SendFormActions'; +import * as SessionStorageActions from 'actions/SessionStorageActions'; + import * as stateUtils from 'reducers/utils'; import type { @@ -16,8 +19,6 @@ import type { Dispatch, State, } from 'flowtype'; -import * as SendFormActions from './SendFormActions'; - export type SelectedAccountAction = { type: typeof ACCOUNT.DISPOSE, @@ -42,17 +43,16 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct // SessionStorageActions.clear(location.pathname); } + if (prevState.sendForm !== state.sendForm) { + dispatch(SessionStorageActions.save()); + } + // handle devices state change (from trezor-connect events or location change) if (locationChange || prevState.accounts !== state.accounts || prevState.discovery !== state.discovery || prevState.tokens !== state.tokens || prevState.pending !== state.pending) { - if (locationChange) { - // dispose current account view - dispatch(dispose()); - } - const account = stateUtils.getSelectedAccount(state); const network = stateUtils.getSelectedNetwork(state); const discovery = stateUtils.getDiscoveryProcess(state); diff --git a/src/actions/SendFormActions.js b/src/actions/SendFormActions.js index 6288c9d6..eab6a786 100644 --- a/src/actions/SendFormActions.js +++ b/src/actions/SendFormActions.js @@ -13,6 +13,7 @@ import { initialState } from 'reducers/SendFormReducer'; import { findToken } from 'reducers/TokensReducer'; import { findDevice, getPendingAmount, getPendingNonce } from 'reducers/utils'; import * as stateUtils from 'reducers/utils'; +import { validateAddress } from 'utils/ethUtils'; import type { Dispatch, @@ -246,7 +247,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS // const gasPrice: BigNumber = new BigNumber(EthereumjsUnits.convert(web3.gasPrice, 'wei', 'gwei')) || new BigNumber(network.defaultGasPrice); - const gasPrice: BigNumber = await dispatch( BlockchainActions.getGasPrice(network.network, network.defaultGasPrice) ); + const gasPrice: BigNumber = await dispatch(BlockchainActions.getGasPrice(network.network, network.defaultGasPrice)); // const gasPrice: BigNumber = new BigNumber(network.defaultGasPrice); const gasLimit: string = network.defaultGasLimit.toString(); const feeLevels: Array = getFeeLevels(network.symbol, gasPrice, gasLimit); @@ -345,32 +346,19 @@ export const validation = (props: Props): void => { if (state.untouched) return; // valid address if (state.touched.address) { - /* if (state.address.length < 1) { - errors.address = 'Address is not set'; - } else if (!EthereumjsUtil.isValidAddress(state.address)) { - errors.address = 'Address is not valid'; - } else { - // address warning or info are set in addressValidation ThunkAction - // do not override this - if (state.warnings.address) { - warnings.address = state.warnings.address; - } else if (state.infos.address) { - infos.address = state.infos.address; - } - } */ - - /* eslint (no-lonely-if) */ - if (state.address.length < 1) { - errors.address = 'Address is not set'; - } else if (!EthereumjsUtil.isValidAddress(state.address)) { - errors.address = 'Address is not valid'; - } else if (state.warnings.address) { - // address warning or info are set in addressValidation ThunkAction - // do not override this + const addressError = validateAddress(state.address); + if (addressError) { + errors.address = addressError; + } + + // address warning or info may be set in addressValidation ThunkAction + // do not override them + if (state.warnings.address) { warnings.address = state.warnings.address; - if (state.infos.address) { - infos.address = state.infos.address; - } + } + + if (state.infos.address) { + infos.address = state.infos.address; } } @@ -733,7 +721,7 @@ const estimateGasPrice = (): AsyncAction => async (dispatch: Dispatch, getState: return; } - const gasLimit: number = await dispatch( BlockchainActions.estimateGasLimit(network.network, state.data, state.amount, state.gasPrice) ); + const gasLimit: number = await dispatch(BlockchainActions.estimateGasLimit(network.network, state.data, state.amount, state.gasPrice)); if (getState().sendForm.data === requestedData) { dispatch(onGasLimitChange(gasLimit.toString())); @@ -783,7 +771,7 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge const pendingNonce: number = stateUtils.getPendingNonce(pending); const nonce = pendingNonce > 0 && pendingNonce >= account.nonce ? pendingNonce : account.nonce; - const txData = await dispatch( prepareEthereumTx({ + const txData = await dispatch(prepareEthereumTx({ network: network.network, token: isToken ? findToken(getState().tokens, account.address, currentState.currency, account.deviceState) : null, from: account.address, @@ -792,8 +780,8 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge data: currentState.data, gasLimit: currentState.gasLimit, gasPrice: currentState.gasPrice, - nonce - }) ); + nonce, + })); const selected: ?TrezorDevice = getState().wallet.selectedDevice; if (!selected) return; @@ -828,14 +816,14 @@ export const onSend = (): AsyncAction => async (dispatch: Dispatch, getState: Ge txData.v = signedTransaction.payload.v; try { - const serializedTx: string = await dispatch( serializeEthereumTx(txData) ); + const serializedTx: string = await dispatch(serializeEthereumTx(txData)); const push = await TrezorConnect.pushTransaction({ tx: serializedTx, - coin: network.network + coin: network.network, }); - + if (!push.success) { - throw new Error( push.payload.error ); + throw new Error(push.payload.error); } const txid = push.payload.txid; diff --git a/src/actions/TrezorConnectActions.js b/src/actions/TrezorConnectActions.js index 94f99347..7d718906 100644 --- a/src/actions/TrezorConnectActions.js +++ b/src/actions/TrezorConnectActions.js @@ -1,13 +1,11 @@ /* @flow */ import TrezorConnect, { - UI, DEVICE, DEVICE_EVENT, UI_EVENT, TRANSPORT_EVENT, BLOCKCHAIN_EVENT + DEVICE, DEVICE_EVENT, UI_EVENT, TRANSPORT_EVENT, BLOCKCHAIN_EVENT, } from 'trezor-connect'; import * as CONNECT from 'actions/constants/TrezorConnect'; import * as NOTIFICATION from 'actions/constants/notification'; -import * as WALLET from 'actions/constants/wallet'; import { getDuplicateInstanceNumber } from 'reducers/utils'; - -import { push } from 'react-router-redux'; +import * as RouterActions from 'actions/RouterActions'; import type { DeviceMessage, @@ -28,7 +26,6 @@ import type { AsyncAction, Device, TrezorDevice, - RouterLocationState, } from 'flowtype'; import * as DiscoveryActions from './DiscoveryActions'; @@ -79,19 +76,11 @@ export type TrezorConnectAction = { type: typeof CONNECT.STOP_ACQUIRING, }; - -const sortDevices = (devices: Array): Array => devices.sort((a, b) => { - if (!a.ts || !b.ts) { - return -1; - } - return a.ts > b.ts ? -1 : 1; -}); - export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { // set listeners TrezorConnect.on(DEVICE_EVENT, (event: DeviceMessage): void => { // post event to reducers - const type: DeviceMessageType = event.type; // assert flow type + const type: DeviceMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, device: event.payload, @@ -100,7 +89,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(UI_EVENT, (event: UiMessage): void => { // post event to reducers - const type: UiMessageType = event.type; // assert flow type + const type: UiMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, @@ -109,7 +98,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(TRANSPORT_EVENT, (event: TransportMessage): void => { // post event to reducers - const type: TransportMessageType = event.type; // assert flow type + const type: TransportMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, @@ -118,21 +107,22 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainMessage): void => { // post event to reducers - const type: BlockchainMessageType = event.type; // assert flow type + const type: BlockchainMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, }); }); + /* global LOCAL */ // $FlowIssue LOCAL not declared - window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://sisyfos.trezor.io/connect/'; - // window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://connect.trezor.io/5/'; + window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://sisyfos.trezor.io/connect/'; // eslint-disable-line no-underscore-dangle + // window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://connect.trezor.io/5/'; // eslint-disable-line no-underscore-dangle try { await TrezorConnect.init({ transportReconnect: true, - debug: true, + debug: false, popup: false, webusb: true, pendingTransportEvent: (getState().devices.length < 1), @@ -145,67 +135,11 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS } }; -// selection from Aside dropdown button -// after device_connect event -// or after acquiring device -// device type could be local TrezorDevice or Device (from trezor-connect device_connect event) -export const onSelectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - // || device.isUsedElsewhere - - // switch to initial url and reset this value - - if (!device.features) { - dispatch(push(`/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`)); - } else if (device.features.bootloader_mode) { - dispatch(push(`/device/${device.path}/bootloader`)); - } else if (!device.features.initialized) { - dispatch(push(`/device/${device.features.device_id}/initialize`)); - } else if (typeof device.instance === 'number') { - dispatch(push(`/device/${device.features.device_id}:${device.instance}`)); - } else { - const deviceId: string = device.features.device_id; - const urlParams: RouterLocationState = getState().router.location.state; - // let url: string = `/device/${ device.features.device_id }/network/ethereum/account/0`; - let url: string = `/device/${deviceId}`; - let instance: ?number; - // check if device is not TrezorDevice type - if (!device.hasOwnProperty('ts')) { - // its device from trezor-connect (called in initConnectedDevice triggered by device_connect event) - // need to lookup if there are unavailable instances - const available: Array = getState().devices.filter(d => d.path === device.path); - const latest: Array = sortDevices(available); - - if (latest.length > 0 && latest[0].instance) { - url += `:${latest[0].instance}`; - instance = latest[0].instance; - } - } - // check if current location is not set to this device - //dispatch( push(`/device/${ device.features.device_id }/network/etc/account/0`) ); - - if (urlParams.deviceInstance !== instance || urlParams.device !== deviceId) { - dispatch(push(url)); - } - } -}; - -export const initConnectedDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch/* , getState: GetState */): void => { - // const selected = getState().wallet.selectedDevice; - // if (!selected || (selected && selected.state)) { - dispatch(onSelectDevice(device)); - // } - // if (device.unacquired && selected && selected.path !== device.path && !selected.connected) { - // dispatch( onSelectDevice(device) ); - // } else if (!selected) { - // dispatch( onSelectDevice(device) ); - // } -}; - // called after backend was initialized // set listeners for connect/disconnect -export const postInit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { +export const postInit = (): ThunkAction => (dispatch: Dispatch): void => { const handleDeviceConnect = (device: Device) => { - dispatch(initConnectedDevice(device)); + dispatch(RouterActions.selectDevice(device)); }; TrezorConnect.off(DEVICE.CONNECT, handleDeviceConnect); @@ -214,58 +148,13 @@ export const postInit = (): ThunkAction => (dispatch: Dispatch, getState: GetSta TrezorConnect.on(DEVICE.CONNECT, handleDeviceConnect); TrezorConnect.on(DEVICE.CONNECT_UNACQUIRED, handleDeviceConnect); - const { devices } = getState(); - - const { initialPathname, initialParams } = getState().wallet; - - if (initialPathname) { - dispatch({ - type: WALLET.SET_INITIAL_URL, - // pathname: null, - // params: null - }); - } - - if (devices.length > 0) { - const unacquired: ?TrezorDevice = devices.find(d => d.features); - if (unacquired) { - dispatch(onSelectDevice(unacquired)); - } else { - const latest: Array = sortDevices(devices); - const firstConnected: ?TrezorDevice = latest.find(d => d.connected); - dispatch(onSelectDevice(firstConnected || latest[0])); - - // TODO - if (initialParams) { - if (!initialParams.hasOwnProperty('network') && initialPathname !== getState().router.location.pathname) { - // dispatch( push(initialPathname) ); - } - } - } + // try to redirect to initial url + if (!dispatch(RouterActions.setInitialUrl())) { + // if initial redirection fails try to switch to first available device + dispatch(RouterActions.selectFirstAvailableDevice()); } }; -export const switchToFirstAvailableDevice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { devices } = getState(); - if (devices.length > 0) { - // TODO: Priority: - // 1. First Unacquired - // 2. First connected - // 3. Saved with latest timestamp - const unacquired = devices.find(d => !d.features); - if (unacquired) { - dispatch(initConnectedDevice(unacquired)); - } else { - const latest: Array = sortDevices(devices); - const firstConnected: ?TrezorDevice = latest.find(d => d.connected); - dispatch(onSelectDevice(firstConnected || latest[0])); - } - } else { - dispatch(push('/')); - } -}; - - export const getSelectedDeviceState = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const selected = getState().wallet.selectedDevice; if (selected @@ -345,7 +234,7 @@ export const coinChanged = (network: ?string): ThunkAction => (dispatch: Dispatc }; export function reload(): AsyncAction { - return async (dispatch: Dispatch, getState: GetState): Promise => { + return async (): Promise => { }; } @@ -391,13 +280,6 @@ export function acquire(): AsyncAction { }; } -export const gotoDeviceSettings = (device: TrezorDevice): ThunkAction => (dispatch: Dispatch): void => { - if (device.features) { - const devUrl: string = `${device.features.device_id}${device.instance ? `:${device.instance}` : ''}`; - dispatch(push(`/device/${devUrl}/settings`)); - } -}; - // called from Aside - device menu (forget single instance) export const forget = (device: TrezorDevice): Action => ({ type: CONNECT.FORGET_REQUEST, diff --git a/src/components/Checkbox/index.js b/src/components/Checkbox/index.js index 1f4eef2b..fad58722 100644 --- a/src/components/Checkbox/index.js +++ b/src/components/Checkbox/index.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import styled from 'styled-components'; +import styled, { css } from 'styled-components'; import colors from 'config/colors'; import Icon from 'components/Icon'; import icons from 'config/icons'; @@ -34,8 +34,10 @@ const IconWrapper = styled.div` &:hover, &:focus { - border: 1px solid ${colors.TEXT_PRIMARY}; - background: ${props => (props.checked ? colors.TEXT_PRIMARY : colors.WHITE)}; + ${props => !props.checked && css` + border: 1px solid ${colors.GREEN_PRIMARY}; + `} + background: ${props => (props.checked ? colors.GREEN_PRIMARY : colors.WHITE)}; } `; @@ -74,7 +76,12 @@ class Checkbox extends PureComponent { {checked && ( - + ) } diff --git a/src/components/Footer/index.js b/src/components/Footer/index.js index fa695d98..8f19462c 100644 --- a/src/components/Footer/index.js +++ b/src/components/Footer/index.js @@ -29,7 +29,7 @@ const Copy = styled.div` const Footer = ({ toggle }) => ( - © {getYear(new Date())} + © {getYear(new Date())} SatoshiLabs Terms Show Log diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index 6e5b0d71..1879547c 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -27,7 +27,7 @@ const SvgWrapper = styled.svg` :hover { path { - fill: ${props => props.hoverColor || colors.TEXT_SECONDARY} + fill: ${props => props.hoverColor} } } `; diff --git a/src/components/Paragraph/index.js b/src/components/Paragraph/index.js index 88a24fb8..34f498de 100644 --- a/src/components/Paragraph/index.js +++ b/src/components/Paragraph/index.js @@ -26,10 +26,7 @@ const P = ({ children, className, isSmaller = false }) => ( P.propTypes = { className: PropTypes.string, isSmaller: PropTypes.bool, - children: PropTypes.oneOfType([ - PropTypes.array, - PropTypes.string, - ]), + children: PropTypes.node, }; export default P; diff --git a/src/components/Select/index.js b/src/components/Select/index.js index eefbac2a..5e94cdd3 100644 --- a/src/components/Select/index.js +++ b/src/components/Select/index.js @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import ReactSelect from 'react-select'; import ReactAsyncSelect from 'react-select/lib/Async'; import colors from 'config/colors'; -import { FONT_FAMILY } from 'config/variables'; const styles = isSearchable => ({ singleValue: base => ({ @@ -13,7 +12,6 @@ const styles = isSearchable => ({ }), control: (base, { isDisabled }) => ({ ...base, - fontFamily: FONT_FAMILY.MONOSPACE, minHeight: 'initial', height: '100%', borderRadius: '2px', @@ -45,17 +43,16 @@ const styles = isSearchable => ({ menuList: base => ({ ...base, padding: 0, - fontFamily: FONT_FAMILY.MONOSPACE, boxShadow: 'none', background: colors.WHITE, borderLeft: `1px solid ${colors.DIVIDER}`, borderRight: `1px solid ${colors.DIVIDER}`, borderBottom: `1px solid ${colors.DIVIDER}`, }), - option: (base, { isSelected }) => ({ + option: (base, { isFocused }) => ({ ...base, color: colors.TEXT_SECONDARY, - background: isSelected ? colors.LANDING : colors.WHITE, + background: isFocused ? colors.LANDING : colors.WHITE, borderRadius: 0, '&:hover': { cursor: 'pointer', diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 656c0b19..8f94ad15 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -3,19 +3,13 @@ import RcTooltip from 'rc-tooltip'; import colors from 'config/colors'; import styled from 'styled-components'; import PropTypes from 'prop-types'; -import { FONT_SIZE } from 'config/variables'; - -const TooltipContent = styled.div` - width: ${props => (props.isAside ? '260px' : '320px')}; - font-size: ${FONT_SIZE.SMALLEST}; -`; const Wrapper = styled.div` .rc-tooltip { + max-width: ${props => `${props.maxWidth}px` || 'auto'}; position: absolute; z-index: 1070; display: block; - background: red; visibility: visible; border: 1px solid ${colors.DIVIDER}; border-radius: 3px; @@ -178,9 +172,11 @@ class Tooltip extends Component { placement, content, children, + maxWidth, } = this.props; return ( { this.tooltipContainerRef = node; }} > @@ -188,7 +184,7 @@ class Tooltip extends Component { getTooltipContainer={() => this.tooltipContainerRef} arrowContent={
} placement={placement} - overlay={{content}} + overlay={content} > {children} @@ -204,6 +200,7 @@ Tooltip.propTypes = { PropTypes.element, PropTypes.string, ]), + maxWidth: PropTypes.number, content: PropTypes.oneOfType([ PropTypes.element, PropTypes.string, diff --git a/src/components/inputs/Input/index.js b/src/components/inputs/Input/index.js index 339b73cc..407ff1cb 100644 --- a/src/components/inputs/Input/index.js +++ b/src/components/inputs/Input/index.js @@ -8,7 +8,6 @@ import { FONT_SIZE, FONT_WEIGHT, TRANSITION, - FONT_FAMILY, } from 'config/variables'; const Wrapper = styled.div` @@ -35,10 +34,9 @@ const TopLabel = styled.span` const StyledInput = styled.input` width: 100%; - padding: 6px 12px; + padding: 6px ${props => (props.hasIcon ? '40px' : '12px')} 6px 12px; line-height: 1.42857143; - font-family: ${FONT_FAMILY.MONOSPACE}; font-size: ${FONT_SIZE.SMALL}; font-weight: ${FONT_WEIGHT.BASE}; color: ${colors.TEXT_PRIMARY}; @@ -114,6 +112,8 @@ class Input extends Component { /> )} {

{`${amount} ${currency}` }

-

{ address }

+ { address }

{ selectedFeeLevel.label }

diff --git a/src/components/modals/passphrase/Passphrase/index.js b/src/components/modals/passphrase/Passphrase/index.js index cbfc3801..f9a5e1f3 100644 --- a/src/components/modals/passphrase/Passphrase/index.js +++ b/src/components/modals/passphrase/Passphrase/index.js @@ -2,6 +2,7 @@ import React, { Component } from 'react'; import raf from 'raf'; import colors from 'config/colors'; +import { H2 } from 'components/Heading'; import P from 'components/Paragraph'; import { FONT_SIZE } from 'config/variables'; import Link from 'components/Link'; @@ -23,7 +24,10 @@ const Label = styled.div` padding-bottom: 5px; `; -const PassphraseError = styled.div``; +const PassphraseError = styled.div` + margin-top: 8px; + color: ${colors.ERROR_PRIMARY}; +`; const Row = styled.div` position: relative; @@ -137,22 +141,25 @@ export default class PinModal extends Component { } = this.state; // } = this.props.modal; - let passphraseInputValue: string = passphrase; - let passphraseRevisionInputValue: string = passphraseRevision; - if (!visible && !passphraseFocused) { - passphraseInputValue = passphrase.replace(/./g, '•'); - } - if (!visible && !passphraseRevisionFocused) { - passphraseRevisionInputValue = passphraseRevision.replace(/./g, '•'); - } + const passphraseInputValue: string = passphrase; + const passphraseRevisionInputValue: string = passphraseRevision; + // if (!visible && !passphraseFocused) { + // passphraseInputValue = passphrase.replace(/./g, '•'); + // } + // if (!visible && !passphraseRevisionFocused) { + // passphraseRevisionInputValue = passphraseRevision.replace(/./g, '•'); + // } + if (this.passphraseInput) { - this.passphraseInput.value = passphraseInputValue; - this.passphraseInput.setAttribute('type', visible || (!visible && !passphraseFocused) ? 'text' : 'password'); + // this.passphraseInput.value = passphraseInputValue; + // this.passphraseInput.setAttribute('type', visible || (!visible && !passphraseFocused) ? 'text' : 'password'); + this.passphraseInput.setAttribute('type', visible ? 'text' : 'password'); } if (this.passphraseRevisionInput) { - this.passphraseRevisionInput.value = passphraseRevisionInputValue; - this.passphraseRevisionInput.setAttribute('type', visible || (!visible && !passphraseRevisionFocused) ? 'text' : 'password'); + // this.passphraseRevisionInput.value = passphraseRevisionInputValue; + // this.passphraseRevisionInput.setAttribute('type', visible || (!visible && !passphraseRevisionFocused) ? 'text' : 'password'); + this.passphraseRevisionInput.setAttribute('type', visible ? 'text' : 'password'); } } @@ -174,6 +181,14 @@ export default class PinModal extends Component { match: previousState.singleInput || previousState.passphraseRevision === value, passphrase: value, })); + + if (this.state.visible && this.passphraseRevisionInput) { + this.setState({ + match: true, + passphraseRevision: value, + }); + this.passphraseRevisionInput.value = value; + } } else { this.setState(previousState => ({ match: previousState.passphrase === value, @@ -211,12 +226,29 @@ export default class PinModal extends Component { this.setState({ visible: true, }); + if (this.passphraseRevisionInput) { + this.passphraseRevisionInput.disabled = true; + this.passphraseRevisionInput.value = this.state.passphrase; + this.setState(previousState => ({ + passphraseRevision: previousState.passphrase, + match: true, + })); + } } onPassphraseHide = (): void => { this.setState({ visible: false, }); + if (this.passphraseRevisionInput) { + const emptyPassphraseRevisionValue = ''; + this.passphraseRevisionInput.value = emptyPassphraseRevisionValue; + this.setState(previousState => ({ + passphraseRevision: emptyPassphraseRevisionValue, + match: emptyPassphraseRevisionValue === previousState.passphrase, + })); + this.passphraseRevisionInput.disabled = false; + } } submit = (empty: boolean = false): void => { @@ -232,7 +264,7 @@ export default class PinModal extends Component { //this.passphraseRevisionInput.style.display = 'none'; //this.passphraseRevisionInput.setAttribute('readonly', 'readonly'); - const p = passphrase; + // const p = passphrase; this.setState({ passphrase: '', @@ -271,11 +303,10 @@ export default class PinModal extends Component { //let passphraseRevisionInputType: string = visible || passphraseRevisionFocused ? "text" : "password"; const showPassphraseCheckboxFn: Function = visible ? this.onPassphraseHide : this.onPassphraseShow; - console.log('passphraseInputType', passphraseInputType); return ( - {/* ?

Enter { deviceLabel } passphrase

*/} - {/*

Note that passphrase is case-sensitive.

*/} +

Enter { deviceLabel } passphrase

+

Note that passphrase is case-sensitive.

{ data-lpignore="true" onFocus={() => this.onPassphraseFocus('passphrase')} onBlur={() => this.onPassphraseBlur('passphrase')} - tabIndex="1" + tabIndex="0" /> {!singleInput && ( @@ -306,7 +337,6 @@ export default class PinModal extends Component { data-lpignore="true" onFocus={() => this.onPassphraseFocus('revision')} onBlur={() => this.onPassphraseBlur('revision')} - tabIndex="2" /> {!match && passphraseRevisionTouched && Passphrases do not match } @@ -315,7 +345,7 @@ export default class PinModal extends Component { Show passphrase - +