diff --git a/.flowconfig b/.flowconfig index 52287bb3..dfa60b01 100644 --- a/.flowconfig +++ b/.flowconfig @@ -10,6 +10,8 @@ .*/node_modules/oboe/test/.* .*/_old/.* .*/public/solidity/.* +.*/build/.* +.*/build-devel/.* [libs] ./src/flowtype/npm/redux_v3.x.x.js diff --git a/README.md b/README.md index ae2b91c2..dcd55a02 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# TREZOR Wallet +# Trezor Ethereum wallet To install dependencies run `npm install` or `yarn` diff --git a/browserslist b/browserslist index 70900640..dc070217 100644 --- a/browserslist +++ b/browserslist @@ -1,4 +1,4 @@ # Browsers that we support > 5% -IE > 9 # sorry -last 10 versions \ No newline at end of file +IE > 8 +last 5 versions \ No newline at end of file diff --git a/package.json b/package.json index 92ca957d..8f0cc7ab 100644 --- a/package.json +++ b/package.json @@ -60,8 +60,8 @@ "redux-logger": "^3.0.6", "redux-raven-middleware": "^1.2.0", "redux-thunk": "^2.2.0", + "styled-components": "^3.4.9", "rimraf": "^2.6.2", - "styled-components": "^3.3.3", "styled-media-query": "^2.0.2", "styled-normalize": "^8.0.0", "trezor-connect": "^5.0.32", diff --git a/public/unsupported-browsers/browser-chrome.png b/public/unsupported-browsers/browser-chrome.png new file mode 100644 index 00000000..29573624 Binary files /dev/null and b/public/unsupported-browsers/browser-chrome.png differ diff --git a/public/unsupported-browsers/browser-firefox.png b/public/unsupported-browsers/browser-firefox.png new file mode 100644 index 00000000..577e6dc0 Binary files /dev/null and b/public/unsupported-browsers/browser-firefox.png differ diff --git a/public/unsupported-browsers/style.css b/public/unsupported-browsers/style.css new file mode 100644 index 00000000..57a2394d --- /dev/null +++ b/public/unsupported-browsers/style.css @@ -0,0 +1,64 @@ +html, body, div, span, applet, object, iframe, +h1, h2, h3, h4, h5, h6, p, blockquote, pre, +a, abbr, acronym, address, big, cite, code, +del, dfn, em, img, ins, kbd, q, s, samp, +small, strike, strong, sub, sup, tt, var, +b, u, i, center, +dl, dt, dd, ol, ul, li, +fieldset, form, label, legend, +table, caption, tbody, tfoot, thead, tr, th, td, +article, aside, canvas, details, embed, +figure, figcaption, footer, header, hgroup, +menu, nav, output, ruby, section, summary, +time, mark, audio, video { + margin: 0; + padding: 0; + border: 0; + font-size: 100%; + font: inherit; + background: #ebebeb; + vertical-align: baseline; + text-align: center; + font-family: Roboto; +} + +.header { + height: 40px; + width: 100%; + background: #1A1A1A; + margin-bottom: 100px; +} + +.unsupported-browsers { + width: 100%; + font-size: 30px; +} + +.main { + width: 100%; + text-align: center; + margin: 0 auto; +} + +.box { + margin-top: 20px; +} + +p { + width: 100%; + clear: both; + font-size: 15px; +} + +img { + clear: both; +} + +a { + clear: both; + color: #01B757; + width: 100%; + text-decoration: none; + display: block; + font-size: 20px; +} \ No newline at end of file diff --git a/src/actions/PendingTxActions.js b/src/actions/PendingTxActions.js index b4f398d3..a6377171 100644 --- a/src/actions/PendingTxActions.js +++ b/src/actions/PendingTxActions.js @@ -2,8 +2,13 @@ import * as PENDING from 'actions/constants/pendingTx'; + +import type { + Action, ThunkAction, GetState, Dispatch, +} from 'flowtype'; import type { State, PendingTx } from 'reducers/PendingTxReducer'; + export type PendingTxAction = { type: typeof PENDING.FROM_STORAGE, payload: State @@ -15,9 +20,34 @@ export type PendingTxAction = { tx: PendingTx, receipt?: Object, } | { - type: typeof PENDING.TX_NOT_FOUND, + type: typeof PENDING.TX_REJECTED, tx: PendingTx, } | { type: typeof PENDING.TX_TOKEN_ERROR, tx: PendingTx, -} \ No newline at end of file +} + +export const reject = (tx: PendingTx): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + /* + dispatch({ + type: NOTIFICATION.ADD, + payload: { + type: 'warning', + title: 'Pending transaction rejected', + message: `Transaction with id: ${tx.id} not found.`, + cancelable: true, + actions: [ + { + label: 'OK', + callback: () => { + dispatch({ + type: PENDING.TX_RESOLVED, + tx, + }); + }, + }, + ], + }, + }); + */ +}; \ No newline at end of file diff --git a/src/actions/RouterActions.js b/src/actions/RouterActions.js index afa68ebe..d513611f 100644 --- a/src/actions/RouterActions.js +++ b/src/actions/RouterActions.js @@ -152,7 +152,7 @@ export const getValidUrl = (action: RouterAction): PayloadAction => (dis // Disallow displaying landing page // redirect to previous url if (!shouldBeLandingPage && landingPageUrl) { - return location.pathname; + return dispatch(getFirstAvailableDeviceUrl()) || location.pathname; } // Regular url change during application live cycle @@ -170,22 +170,10 @@ export const getValidUrl = (action: RouterAction): PayloadAction => (dis return composedUrl || location.pathname; }; - /* -* Utility used in "selectDevice" and "selectFirstAvailableDevice" -* sorting device array by "ts" (timestamp) field +* Compose url from requested device object and returns url */ -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 => { +const getDeviceUrl = (device: TrezorDevice | Device): PayloadAction => (dispatch: Dispatch, getState: GetState): ?string => { let url: ?string; if (!device.features) { url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`; @@ -208,12 +196,7 @@ export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dis } } } - - const currentParams: RouterLocationState = getState().router.location.state; - const requestedParams = dispatch(pathToParams(url)); - if (currentParams.device !== requestedParams.device || currentParams.deviceInstance !== requestedParams.deviceInstance) { - dispatch(goto(url)); - } + return url; }; /* @@ -223,17 +206,54 @@ export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dis * 3. Saved with latest timestamp * OR redirect to landing page */ -export const selectFirstAvailableDevice = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { +export const getFirstAvailableDeviceUrl = (): PayloadAction => (dispatch: Dispatch, getState: GetState): ?string => { const { devices } = getState(); + let url: ?string; if (devices.length > 0) { const unacquired = devices.find(d => !d.features); if (unacquired) { - dispatch(selectDevice(unacquired)); + url = dispatch(getDeviceUrl(unacquired)); } else { const latest: Array = sortDevices(devices); const firstConnected: ?TrezorDevice = latest.find(d => d.connected); - dispatch(selectDevice(firstConnected || latest[0])); + url = dispatch(getDeviceUrl(firstConnected || latest[0])); } + } + return url; +}; + +/* +* Utility used in "getDeviceUrl" and "getFirstAvailableDeviceUrl" +* 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; +}); + +/* +* Redirect to requested device +*/ +export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const url: ?string = dispatch(getDeviceUrl(device)); + if (!url) return; + + const currentParams: RouterLocationState = getState().router.location.state; + const requestedParams = dispatch(pathToParams(url)); + if (currentParams.device !== requestedParams.device || currentParams.deviceInstance !== requestedParams.deviceInstance) { + dispatch(goto(url)); + } +}; + +/* +* Redirect to first device or landing page +*/ +export const selectFirstAvailableDevice = (): ThunkAction => (dispatch: Dispatch): void => { + const url = dispatch(getFirstAvailableDeviceUrl()); + if (url) { + dispatch(goto(url)); } else { dispatch(gotoLandingPage()); } @@ -256,8 +276,7 @@ export const isLandingPageUrl = ($url?: string): PayloadAction => (disp 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'); + return url === '/'; }; /* diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index 4e01c1a5..53e8b62f 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -1,100 +1,210 @@ /* @flow */ - - import { LOCATION_CHANGE } from 'react-router-redux'; +import * as WALLET from 'actions/constants/wallet'; import * as ACCOUNT from 'actions/constants/account'; -import * as NOTIFICATION from 'actions/constants/notification'; +import * as DISCOVERY from 'actions/constants/discovery'; +import * as TOKEN from 'actions/constants/token'; import * as PENDING from 'actions/constants/pendingTx'; -import * as stateUtils from 'reducers/utils'; +import * as reducerUtils from 'reducers/utils'; import type { - AsyncAction, + PayloadAction, Action, GetState, Dispatch, State, } from 'flowtype'; +type SelectedAccountState = $ElementType; + export type SelectedAccountAction = { type: typeof ACCOUNT.DISPOSE, } | { type: typeof ACCOUNT.UPDATE_SELECTED_ACCOUNT, - payload: $ElementType + payload: SelectedAccountState, }; +type AccountStatus = { + type: string; // notification type + title: string; // notification title + message?: string; // notification message + shouldRender: boolean; // should render account page +} + export const dispose = (): Action => ({ type: ACCOUNT.DISPOSE, }); -export const updateSelectedValues = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const locationChange: boolean = action.type === LOCATION_CHANGE; - const state: State = getState(); - const { location } = state.router; +const getAccountStatus = (state: State, selectedAccount: SelectedAccountState): ?AccountStatus => { + const device = state.wallet.selectedDevice; + if (!device || !device.state) { + return { + type: 'info', + title: 'Loading device...', + shouldRender: false, + }; + } + + const { + account, + discovery, + network, + } = selectedAccount; + + // corner case: accountState didn't finish loading state after LOCATION_CHANGE action + if (!network) { + return { + type: 'info', + title: 'Loading account state...', + shouldRender: false, + }; + } - // 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) { - const account = stateUtils.getSelectedAccount(state); - const network = stateUtils.getSelectedNetwork(state); - const discovery = stateUtils.getDiscoveryProcess(state); - const tokens = stateUtils.getAccountTokens(state, account); - const pending = stateUtils.getAccountPendingTx(state.pending, account); - - const payload: $ElementType = { - location: location.pathname, - account, - network, - discovery, - tokens, - pending, + const blockchain = state.blockchain.find(b => b.name === network.network); + if (blockchain && !blockchain.connected) { + return { + type: 'backend', + title: `${network.name} backend is not connected`, + shouldRender: false, }; + } - let needUpdate: boolean = false; - Object.keys(payload).forEach((key) => { - if (Array.isArray(payload[key])) { - if (Array.isArray(state.selectedAccount[key]) && payload[key].length !== state.selectedAccount[key].length) { - needUpdate = true; + // account not found (yet). checking why... + if (!account) { + if (!discovery || discovery.waitingForDevice) { + if (device.connected) { + // case 1: device is connected but discovery not started yet (probably waiting for auth) + if (device.available) { + return { + type: 'info', + title: 'Loading accounts...', + shouldRender: false, + }; } - } else if (payload[key] !== state.selectedAccount[key]) { - needUpdate = true; + // case 2: device is unavailable (created with different passphrase settings) account cannot be accessed + return { + type: 'info', + title: `Device ${device.instanceLabel} is unavailable`, + message: 'Change passphrase settings to use this device', + shouldRender: false, + }; } - }); - if (needUpdate) { - dispatch({ - type: ACCOUNT.UPDATE_SELECTED_ACCOUNT, - payload, - }); - - if (location.state.send) { - const rejectedTxs = pending.filter(tx => tx.rejected); - rejectedTxs.forEach((tx) => { - dispatch({ - type: NOTIFICATION.ADD, - payload: { - type: 'warning', - title: 'Pending transaction rejected', - message: `Transaction with id: ${tx.id} not found.`, - cancelable: true, - actions: [ - { - label: 'OK', - callback: () => { - dispatch({ - type: PENDING.TX_RESOLVED, - tx, - }); - }, - }, - ], - }, - }); - }); - } + // case 3: device is disconnected + return { + type: 'info', + title: `Device ${device.instanceLabel} is disconnected`, + message: 'Connect device to load accounts', + shouldRender: false, + }; } + + if (discovery.completed) { + // case 4: account not found and discovery is completed + return { + type: 'warning', + title: 'Account does not exist', + shouldRender: false, + }; + } + + // case 6: discovery is not completed yet + return { + type: 'info', + title: 'Loading accounts...', + shouldRender: false, + }; + } + + // Additional status: account does exists and it's visible but shouldn't be active + if (!device.connected) { + return { + type: 'info', + title: `Device ${device.instanceLabel} is disconnected`, + shouldRender: true, + }; + } + if (!device.available) { + return { + type: 'info', + title: `Device ${device.instanceLabel} is unavailable`, + message: 'Change passphrase settings to use this device', + shouldRender: true, + }; + } + + // Additional status: account does exists, but waiting for discovery to complete + if (discovery && !discovery.completed) { + return { + type: 'info', + title: 'Loading accounts...', + shouldRender: true, + }; + } + + return null; +}; + +// list of all actions which has influence on "selectedAccount" reducer +// other actions will be ignored +const actions = [ + LOCATION_CHANGE, + WALLET.SET_SELECTED_DEVICE, + WALLET.UPDATE_SELECTED_DEVICE, + ...Object.values(ACCOUNT).filter(v => typeof v === 'string' && v !== ACCOUNT.UPDATE_SELECTED_ACCOUNT), // exported values got unwanted "__esModule: true" as first element + ...Object.values(DISCOVERY).filter(v => typeof v === 'string'), + ...Object.values(TOKEN).filter(v => typeof v === 'string'), + ...Object.values(PENDING).filter(v => typeof v === 'string'), +]; + +/* +* Called from WalletService +*/ +export const observe = (prevState: State, action: Action): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + // ignore not listed actions + if (actions.indexOf(action.type) < 0) return false; + + const state: State = getState(); + const { location } = state.router; + // displayed route is not an account route + if (!location.state.account) return false; + + // get new values for selected account + const account = reducerUtils.getSelectedAccount(state); + const network = reducerUtils.getSelectedNetwork(state); + const discovery = reducerUtils.getDiscoveryProcess(state); + const tokens = reducerUtils.getAccountTokens(state, account); + const pending = reducerUtils.getAccountPendingTx(state.pending, account); + + // prepare new state for "selectedAccount" reducer + const newState: SelectedAccountState = { + location: state.router.location.pathname, + account, + network, + discovery, + tokens, + pending, + notification: null, + shouldRender: false, + }; + + // get "selectedAccount" status from newState + const status = getAccountStatus(state, newState); + newState.notification = status || null; + newState.shouldRender = status ? status.shouldRender : true; + // check if newState is different than previous state + const stateChanged = reducerUtils.observeChanges(prevState.selectedAccount, newState, { + account: ['balance', 'nonce'], + discovery: ['accountIndex', 'interrupted', 'completed', 'waitingForBlockchain', 'waitingForDevice'], + }); + + if (stateChanged) { + // update values in reducer + dispatch({ + type: ACCOUNT.UPDATE_SELECTED_ACCOUNT, + payload: newState, + }); } + return stateChanged; }; diff --git a/src/actions/SendFormActions.js b/src/actions/SendFormActions.js index 1302d611..f26664f3 100644 --- a/src/actions/SendFormActions.js +++ b/src/actions/SendFormActions.js @@ -2,6 +2,7 @@ import TrezorConnect from 'trezor-connect'; import BigNumber from 'bignumber.js'; +import * as ACCOUNT from 'actions/constants/account'; import * as NOTIFICATION from 'actions/constants/notification'; import * as SEND from 'actions/constants/send'; import * as WEB3 from 'actions/constants/web3'; @@ -45,10 +46,21 @@ export type SendFormAction = { type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR, } | SendTxAction; +// list of all actions which has influence on "sendForm" reducer +// other actions will be ignored +const actions = [ + ACCOUNT.UPDATE_SELECTED_ACCOUNT, + WEB3.GAS_PRICE_UPDATED, + ...Object.values(SEND).filter(v => typeof v === 'string'), +]; + /* -* Called from WalletService on EACH action +* Called from WalletService */ export const observe = (prevState: ReducersState, action: Action): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + // ignore not listed actions + if (actions.indexOf(action.type) < 0) return; + const currentState = getState(); // do not proceed if it's not "send" url if (!currentState.router.location.state.send) return; @@ -75,16 +87,9 @@ export const observe = (prevState: ReducersState, action: Action): ThunkAction = let shouldUpdate: boolean = false; // check if "selectedAccount" reducer changed - const selectedAccountChanged = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, ['account', 'tokens', 'pending']); - if (selectedAccountChanged) { - // double check - // there are only few fields that we are interested in - // check them to avoid unnecessary calculation and validation - const accountChanged = reducerUtils.observeChanges(prevState.selectedAccount.account, currentState.selectedAccount.account, ['balance', 'nonce']); - const tokensChanged = reducerUtils.observeChanges(prevState.selectedAccount.tokens, currentState.selectedAccount.tokens); - const pendingChanged = reducerUtils.observeChanges(prevState.selectedAccount.pending, currentState.selectedAccount.pending); - shouldUpdate = accountChanged || tokensChanged || pendingChanged; - } + shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, { + account: ['balance', 'nonce'], + }); // check if "sendForm" reducer changed if (!shouldUpdate) { diff --git a/src/actions/TrezorConnectActions.js b/src/actions/TrezorConnectActions.js index 7d718906..d3813a22 100644 --- a/src/actions/TrezorConnectActions.js +++ b/src/actions/TrezorConnectActions.js @@ -71,9 +71,7 @@ export type TrezorConnectAction = { type: typeof CONNECT.DEVICE_FROM_STORAGE, payload: Array } | { - type: typeof CONNECT.START_ACQUIRING, -} | { - type: typeof CONNECT.STOP_ACQUIRING, + type: typeof CONNECT.START_ACQUIRING | typeof CONNECT.STOP_ACQUIRING, }; export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { @@ -128,10 +126,10 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS pendingTransportEvent: (getState().devices.length < 1), }); } catch (error) { - // dispatch({ - // type: CONNECT.INITIALIZATION_ERROR, - // error - // }) + dispatch({ + type: CONNECT.INITIALIZATION_ERROR, + error, + }); } }; @@ -206,7 +204,7 @@ export const getSelectedDeviceState = (): AsyncAction => async (dispatch: Dispat export const deviceDisconnect = (device: Device): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const selected: ?TrezorDevice = getState().wallet.selectedDevice; - if (device && device.features) { + if (device.features) { if (selected && selected.features && selected.features.device_id === device.features.device_id) { dispatch(DiscoveryActions.stop(selected)); } @@ -218,7 +216,11 @@ export const deviceDisconnect = (device: Device): AsyncAction => async (dispatch device: instances[0], instances, }); + } else { + dispatch(RouterActions.selectFirstAvailableDevice()); } + } else { + dispatch(RouterActions.selectFirstAvailableDevice()); } }; diff --git a/src/actions/WalletActions.js b/src/actions/WalletActions.js index 9126c541..0d572f4f 100644 --- a/src/actions/WalletActions.js +++ b/src/actions/WalletActions.js @@ -1,16 +1,16 @@ /* @flow */ - import { LOCATION_CHANGE } from 'react-router-redux'; +import { DEVICE } from 'trezor-connect'; import * as WALLET from 'actions/constants/wallet'; -import * as stateUtils from 'reducers/utils'; +import * as reducerUtils from 'reducers/utils'; import type { Device, TrezorDevice, RouterLocationState, ThunkAction, - AsyncAction, + PayloadAction, Action, Dispatch, GetState, @@ -80,26 +80,40 @@ export const clearUnavailableDevicesData = (prevState: State, device: Device): T } }; +// list of all actions which has influence on "selectedDevice" field in "wallet" reducer +// other actions will be ignored +const actions = [ + LOCATION_CHANGE, + ...Object.values(DEVICE).filter(v => typeof v === 'string'), +]; + +/* +* Called from WalletService +*/ +export const observe = (prevState: State, action: Action): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + // ignore not listed actions + if (actions.indexOf(action.type) < 0) return false; -export const updateSelectedValues = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const locationChange: boolean = action.type === LOCATION_CHANGE; const state: State = getState(); + const locationChanged = reducerUtils.observeChanges(prevState.router.location, state.router.location); + const device = reducerUtils.getSelectedDevice(state); + const selectedDeviceChanged = reducerUtils.observeChanges(state.wallet.selectedDevice, device); + // handle devices state change (from trezor-connect events or location change) - if (locationChange || prevState.devices !== state.devices) { - const device = stateUtils.getSelectedDevice(state); - if (state.wallet.selectedDevice !== device) { - if (device && stateUtils.isSelectedDevice(state.wallet.selectedDevice, device)) { - dispatch({ - type: WALLET.UPDATE_SELECTED_DEVICE, - device, - }); - } else { - dispatch({ - type: WALLET.SET_SELECTED_DEVICE, - device, - }); - } + if (locationChanged || selectedDeviceChanged) { + if (device && reducerUtils.isSelectedDevice(state.wallet.selectedDevice, device)) { + dispatch({ + type: WALLET.UPDATE_SELECTED_DEVICE, + device, + }); + } else { + dispatch({ + type: WALLET.SET_SELECTED_DEVICE, + device, + }); } + return true; } + return false; }; \ No newline at end of file diff --git a/src/actions/Web3Actions.js b/src/actions/Web3Actions.js index a8d9491c..53c26a3b 100644 --- a/src/actions/Web3Actions.js +++ b/src/actions/Web3Actions.js @@ -139,7 +139,7 @@ export const resolvePendingTransactions = (network: string): PromiseAction const status = await instance.web3.eth.getTransaction(tx.id); if (!status) { dispatch({ - type: PENDING.TX_NOT_FOUND, + type: PENDING.TX_REJECTED, tx, }); } else { diff --git a/src/actions/constants/pendingTx.js b/src/actions/constants/pendingTx.js index 04f8ef91..08376cc4 100644 --- a/src/actions/constants/pendingTx.js +++ b/src/actions/constants/pendingTx.js @@ -4,5 +4,5 @@ export const FROM_STORAGE: 'pending__from_storage' = 'pending__from_storage'; export const ADD: 'pending__add' = 'pending__add'; export const TX_RESOLVED: 'pending__tx_resolved' = 'pending__tx_resolved'; -export const TX_NOT_FOUND: 'pending__tx_not_found' = 'pending__tx_not_found'; +export const TX_REJECTED: 'pending__tx_rejected' = 'pending__tx_rejected'; export const TX_TOKEN_ERROR: 'pending__tx_token_error' = 'pending__tx_token_error'; \ No newline at end of file diff --git a/src/components/Button/index.js b/src/components/Button/index.js index eb87e626..62d1661b 100644 --- a/src/components/Button/index.js +++ b/src/components/Button/index.js @@ -2,13 +2,13 @@ import React from 'react'; import styled, { css } from 'styled-components'; import PropTypes from 'prop-types'; import colors from 'config/colors'; -import { TRANSITION } from 'config/variables'; +import { TRANSITION, FONT_WEIGHT, FONT_SIZE } from 'config/variables'; const Wrapper = styled.button` padding: ${props => (props.icon ? '4px 24px 4px 15px' : '11px 24px')}; border-radius: 3px; - font-size: 14px; - font-weight: 300; + font-size: ${FONT_SIZE.SMALL}; + font-weight: ${FONT_WEIGHT.SMALLEST}; cursor: pointer; background: ${colors.GREEN_PRIMARY}; color: ${colors.WHITE}; diff --git a/src/components/Checkbox/index.js b/src/components/Checkbox/index.js index fad58722..968788bd 100644 --- a/src/components/Checkbox/index.js +++ b/src/components/Checkbox/index.js @@ -26,18 +26,18 @@ const IconWrapper = styled.div` border-radius: 2px; justify-content: center; align-items: center; - color: ${props => (props.checked ? colors.WHITE : colors.GREEN_PRIMARY)}; - background: ${props => (props.checked ? colors.GREEN_PRIMARY : colors.WHITE)}; - border: 1px solid ${props => (props.checked ? colors.GREEN_PRIMARY : colors.DIVIDER)}; + color: ${props => (props.isChecked ? colors.WHITE : colors.GREEN_PRIMARY)}; + background: ${props => (props.isChecked ? colors.GREEN_PRIMARY : colors.WHITE)}; + border: 1px solid ${props => (props.isChecked ? colors.GREEN_PRIMARY : colors.DIVIDER)}; width: 24px; height: 24px; &:hover, &:focus { - ${props => !props.checked && css` + ${props => !props.isChecked && css` border: 1px solid ${colors.GREEN_PRIMARY}; `} - background: ${props => (props.checked ? colors.GREEN_PRIMARY : colors.WHITE)}; + background: ${props => (props.isChecked ? colors.GREEN_PRIMARY : colors.WHITE)}; } `; @@ -50,7 +50,7 @@ const Label = styled.div` &:hover, &:focus { - color: ${props => (props.checked ? colors.TEXT_PRIMARY : colors.TEXT_PRIMARY)}; + color: ${props => (props.isChecked ? colors.TEXT_PRIMARY : colors.TEXT_PRIMARY)}; } `; @@ -63,7 +63,7 @@ class Checkbox extends PureComponent { render() { const { - checked, + isChecked, children, onClick, } = this.props; @@ -73,20 +73,20 @@ class Checkbox extends PureComponent { onKeyUp={e => this.handleKeyboard(e)} tabIndex={0} > - - {checked && ( + + {isChecked && ( ) } - + ); } @@ -94,7 +94,7 @@ class Checkbox extends PureComponent { Checkbox.propTypes = { onClick: PropTypes.func.isRequired, - checked: PropTypes.bool, + isChecked: PropTypes.bool, children: PropTypes.string, }; diff --git a/src/components/Header/index.js b/src/components/Header/index.js index cbd29c60..6703177d 100644 --- a/src/components/Header/index.js +++ b/src/components/Header/index.js @@ -1,7 +1,7 @@ /* @flow */ import React from 'react'; import styled from 'styled-components'; - +import { NavLink } from 'react-router-dom'; import colors from 'config/colors'; const Wrapper = styled.header` @@ -13,12 +13,9 @@ const Wrapper = styled.header` fill: ${colors.WHITE}; height: 28px; width: 100px; - flex: 1; } `; -const LinkWrapper = styled.div``; - const LayoutWrapper = styled.div` width: 100%; height: 100%; @@ -26,12 +23,21 @@ const LayoutWrapper = styled.div` margin: 0 auto; display: flex; align-items: center; + justify-content: space-between; @media screen and (max-width: 1170px) { padding: 0 25px; } `; +const Left = styled.div` + flex: 1; + display: flex; + justify-content: flex-start; +`; + +const Right = styled.div``; + const A = styled.a` color: ${colors.WHITE}; margin-left: 24px; @@ -55,21 +61,25 @@ const A = styled.a` const Header = (): React$Element => ( - - - - - - - - - - + + + + + + + + + + + + + + TREZOR Docs Blog Support - + ); diff --git a/src/components/Icon/index.js b/src/components/Icon/index.js index 1879547c..05d5f749 100644 --- a/src/components/Icon/index.js +++ b/src/components/Icon/index.js @@ -3,6 +3,16 @@ import PropTypes from 'prop-types'; import colors from 'config/colors'; import styled, { keyframes } from 'styled-components'; +const chooseIconAnimationType = (canAnimate, isActive) => { + if (canAnimate) { + if (isActive) { + return rotate180up; + } + return rotate180down; + } + return null; +}; + // TODO: make animation of icons better const rotate180up = keyframes` from { @@ -23,7 +33,7 @@ const rotate180down = keyframes` `; const SvgWrapper = styled.svg` - animation: ${props => (props.canAnimate ? (props.isActive ? rotate180up : rotate180down) : null)} 0.2s linear 1 forwards; + animation: ${props => chooseIconAnimationType(props.canAnimate, props.isActive)} 0.2s linear 1 forwards; :hover { path { diff --git a/src/components/Notification/NotificationGroups/components/Group/index.js b/src/components/Notification/NotificationGroups/components/Group/index.js new file mode 100644 index 00000000..c3915b77 --- /dev/null +++ b/src/components/Notification/NotificationGroups/components/Group/index.js @@ -0,0 +1,113 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import Icon from 'components/Icon'; +import ICONS from 'config/icons'; +import colors from 'config/colors'; +import { Notification } from 'components/Notification'; +import { getIcon, getColor } from 'utils/notification'; + +const Wrapper = styled.div``; + +const Header = styled.div` + display: flex; + cursor: pointer; + justify-content: space-between; + background: ${colors.WHITE}; + align-items: center; + padding: 5px 10px; + border-bottom: 1px solid ${colors.BACKGROUND}; +`; + +const Left = styled.div` + display: flex; + justify-content: flex-start; + align-items: center; +`; + +const Right = styled.div``; +const Body = styled.div``; + +const Title = styled.div` + color: ${props => props.color}; +`; + +class Group extends Component { + constructor() { + super(); + this.state = { + visibleCount: 1, + visible: true, + }; + } + + toggle = () => { + if (this.state.visible) { + this.setState({ + visible: false, + visibleCount: 0, + }); + } else { + this.setState({ + visible: true, + visibleCount: this.props.groupNotifications.length, + }); + } + } + + render() { + const { type, groupNotifications } = this.props; + const color = getColor(type); + return ( + + {groupNotifications.length > 1 && ( +
+ + + + {groupNotifications.length} {groupNotifications.length > 1 ? `${type}s` : type} + + + + + +
+ )} + + {groupNotifications + .slice(0, this.state.visibleCount) + .map(notification => ( + + ))} + +
+ ); + } +} + +Group.propTypes = { + type: PropTypes.string, + groupNotifications: PropTypes.arrayOf({ + key: PropTypes.string, + type: PropTypes.string, + title: PropTypes.string, + message: PropTypes.string, + }), +}; + +export default Group; \ No newline at end of file diff --git a/src/components/Notification/NotificationGroups/index.js b/src/components/Notification/NotificationGroups/index.js new file mode 100644 index 00000000..715b1da3 --- /dev/null +++ b/src/components/Notification/NotificationGroups/index.js @@ -0,0 +1,56 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; + +import { PRIORITY } from 'constants/notifications'; +import Group from './components/Group'; + +const Wrapper = styled.div``; + +class NotificationsGroup extends Component { + constructor() { + super(); + this.notifications = [ + { type: 'warning', title: 'adddaa', message: 'aaaa' }, + { type: 'error', title: 'aaddda', message: 'aaaa' }, + { type: 'info', title: 'aafffa', message: 'aaaa' }, + { type: 'error', title: 'aggaa', message: 'aaaa' }, + { type: 'warning', title: 'aasssa', message: 'aaaa' }, + { type: 'success', title: 'afaa', message: 'aaaa' }, + { type: 'error', title: 'aada', message: 'aaaa' }, + { type: 'error', title: 'aafffa', message: 'aaaa' }, + ]; + } + + groupNotifications = notifications => notifications + .reduce((acc, obj) => { + const key = obj.type; + if (!acc[key]) { + acc[key] = []; + } + acc[key].push(obj); + return acc; + }, {}); + + sortByPriority(notifications) { + return notifications; + } + + render() { + const { notifications } = this; + const notificationGroups = this.groupNotifications(notifications); + const sortedNotifications = this.sortByPriority(notificationGroups); + + return ( + + {Object.keys(sortedNotifications).map(group => ( + + ))} + + ); + } +} + +export default NotificationsGroup; \ No newline at end of file diff --git a/src/components/Notification/index.js b/src/components/Notification/index.js index 6654a152..4ffa29d1 100644 --- a/src/components/Notification/index.js +++ b/src/components/Notification/index.js @@ -1,22 +1,44 @@ +/* @flow */ + import React from 'react'; +import media from 'styled-media-query'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import styled, { css } from 'styled-components'; import colors from 'config/colors'; +import { getColor, getIcon } from 'utils/notification'; import Icon from 'components/Icon'; import icons from 'config/icons'; import { FONT_SIZE, FONT_WEIGHT } from 'config/variables'; import * as NotificationActions from 'actions/NotificationActions'; import Loader from 'components/Loader'; +import type { Action, State } from 'flowtype'; import NotificationButton from './components/NotificationButton'; +type Props = { + key?: number, + notifications: $ElementType, + close: (notif?: any) => Action, +}; + +type NProps = { + type: string, + cancelable?: boolean; + title: string; + message?: string; + actions?: Array; + close?: typeof NotificationActions.close, + loading?: boolean +}; + const Wrapper = styled.div` position: relative; color: ${colors.TEXT_PRIMARY}; background: ${colors.TEXT_SECONDARY}; padding: 24px 48px 24px 24px; display: flex; + min-height: 90px; flex-direction: row; text-align: left; @@ -39,11 +61,19 @@ const Wrapper = styled.div` color: ${colors.ERROR_PRIMARY}; background: ${colors.ERROR_SECONDARY}; `} + + ${media.lessThan('610px')` + flex-direction: column; + `} `; const Body = styled.div` display: flex; - margin-right: 40px; + width: 60%; + + ${media.lessThan('610px')` + width: 100%; + `} `; const Title = styled.div` @@ -65,54 +95,41 @@ const Message = styled.div` const StyledIcon = styled(Icon)` position: relative; top: -7px; + min-width: 20px; `; -const MessageContent = styled.div` - height: 20px; - display: flex; +const IconWrapper = styled.div` + min-width: 20px; `; const Texts = styled.div` display: flex; flex-direction: column; + flex: 1; `; const AdditionalContent = styled.div` - flex: 1; display: flex; justify-content: flex-end; align-items: flex-end; + flex: 1; `; const ActionContent = styled.div` + display: flex; justify-content: right; align-items: flex-end; + + ${media.lessThan('610px')` + width: 100%; + padding: 10px 0 0 30px; + align-items: flex-start; + `} `; export const Notification = (props: NProps): React$Element => { const close: Function = typeof props.close === 'function' ? props.close : () => {}; // TODO: add default close action - const getIconColor = (type) => { - let color; - switch (type) { - case 'info': - color = colors.INFO_PRIMARY; - break; - case 'error': - color = colors.ERROR_PRIMARY; - break; - case 'warning': - color = colors.WARNING_PRIMARY; - break; - case 'success': - color = colors.SUCCESS_PRIMARY; - break; - default: - color = null; - } - - return color; - }; return ( @@ -120,27 +137,27 @@ export const Notification = (props: NProps): React$Element => { {props.cancelable && ( close()}> )} - + - - { props.title } - { props.message && ( - -

- - ) } - - + + + { props.title } + { props.message && ( + +

+ + ) } + {props.actions && props.actions.length > 0 && ( @@ -161,7 +178,7 @@ export const Notification = (props: NProps): React$Element => { ); }; -export const NotificationGroup = (props) => { +export const NotificationGroup = (props: Props) => { const { notifications, close } = props; return notifications.map(n => ( ({ close: bindActionCreators(NotificationActions.close, dispatch), }), -)(NotificationGroup); \ No newline at end of file +)(NotificationGroup); diff --git a/src/components/Textarea/index.js b/src/components/Textarea/index.js index 05e4719c..d67ba8d7 100644 --- a/src/components/Textarea/index.js +++ b/src/components/Textarea/index.js @@ -15,10 +15,11 @@ const disabledColor = colors.TEXT_PRIMARY; const StyledTextarea = styled.textarea` width: 100%; - padding: 12px; + min-height: 85px; + margin-bottom: 10px; + padding: 6px 12px; box-sizing: border-box; - min-height: 25px; - border: ${props => (props.isError ? `1px solid ${colors.ERROR_PRIMARY}` : `1px solid ${colors.DIVIDER}`)}; + border: 1px solid ${props => (props.borderColor ? props.borderColor : colors.DIVIDER)}; border-radius: 2px; resize: none; outline: none; @@ -56,8 +57,9 @@ const StyledTextarea = styled.textarea` } &:disabled { - border: 1px solid ${disabledColor}; - cursor: not-allowed; + pointer-events: none; + background: ${colors.GRAY_LIGHT}; + color: ${colors.TEXT_SECONDARY}; &::-webkit-input-placeholder { color: ${disabledColor}; @@ -86,6 +88,23 @@ const TopLabel = styled.span` color: ${colors.TEXT_SECONDARY}; `; +const BottomText = styled.span` + font-size: ${FONT_SIZE.SMALLER}; + color: ${props => (props.color ? props.color : colors.TEXT_SECONDARY)}; +`; + +const getColor = (inputState) => { + let color = ''; + if (inputState === 'success') { + color = colors.SUCCESS_PRIMARY; + } else if (inputState === 'warning') { + color = colors.WARNING_PRIMARY; + } else if (inputState === 'error') { + color = colors.ERROR_PRIMARY; + } + return color; +}; + const Textarea = ({ className, placeholder = '', @@ -95,10 +114,13 @@ const Textarea = ({ onBlur, isDisabled, onChange, - isError, topLabel, + state = '', + bottomText = '', }) => ( - + {topLabel && ( {topLabel} )} @@ -111,14 +133,20 @@ const Textarea = ({ value={value} placeholder={placeholder} onChange={onChange} - isError={isError} + borderColor={getColor(state)} /> + {bottomText && ( + + {bottomText} + + )} ); Textarea.propTypes = { className: PropTypes.string, - isError: PropTypes.bool, onFocus: PropTypes.func, onBlur: PropTypes.func, onChange: PropTypes.func, @@ -127,6 +155,8 @@ Textarea.propTypes = { value: PropTypes.string, isDisabled: PropTypes.bool, topLabel: PropTypes.node, + state: PropTypes.string, + bottomText: PropTypes.string, }; export default Textarea; diff --git a/src/components/Tooltip/index.js b/src/components/Tooltip/index.js index 8fa872c6..0a01006b 100644 --- a/src/components/Tooltip/index.js +++ b/src/components/Tooltip/index.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import RcTooltip from 'rc-tooltip'; import colors from 'config/colors'; +import Link from 'components/Link'; import styled from 'styled-components'; import PropTypes from 'prop-types'; @@ -11,7 +12,7 @@ const Wrapper = styled.div` position: absolute; z-index: 1070; visibility: visible; - border: 1px solid ${colors.DIVIDER}; + border: none; border-radius: 3px; box-shadow: 0 3px 8px rgba(0, 0, 0, 0.06); } @@ -22,15 +23,15 @@ const Wrapper = styled.div` .rc-tooltip-inner { padding: 8px 10px; - color: ${colors.TEXT_SECONDARY}; + color: ${colors.WHITE}; font-size: 12px; line-height: 1.5; text-align: left; text-decoration: none; - background-color: ${colors.WHITE}; + background-color: ${colors.TOOLTIP_BACKGROUND}; border-radius: 3px; min-height: 34px; - border: 1px solid ${colors.WHITE}; + border: none; } .rc-tooltip-arrow, @@ -48,7 +49,7 @@ const Wrapper = styled.div` bottom: -6px; margin-left: -6px; border-width: 6px 6px 0; - border-top-color: ${colors.DIVIDER}; + border-top-color: ${colors.BLACK}; } .rc-tooltip-placement-top .rc-tooltip-arrow-inner, @@ -57,7 +58,7 @@ const Wrapper = styled.div` bottom: 2px; margin-left: -6px; border-width: 6px 6px 0; - border-top-color: ${colors.WHITE}; + border-top-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-top .rc-tooltip-arrow { @@ -78,7 +79,7 @@ const Wrapper = styled.div` left: -5px; margin-top: -6px; border-width: 6px 6px 6px 0; - border-right-color: ${colors.DIVIDER}; + border-right-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-right .rc-tooltip-arrow-inner, @@ -87,7 +88,7 @@ const Wrapper = styled.div` left: 1px; margin-top: -6px; border-width: 6px 6px 6px 0; - border-right-color: ${colors.WHITE}; + border-right-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-right .rc-tooltip-arrow { @@ -109,7 +110,7 @@ const Wrapper = styled.div` right: -5px; margin-top: -6px; border-width: 6px 0 6px 6px; - border-left-color: ${colors.DIVIDER}; + border-left-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-left .rc-tooltip-arrow-inner, @@ -118,7 +119,7 @@ const Wrapper = styled.div` right: 1px; margin-top: -6px; border-width: 6px 0 6px 6px; - border-left-color: ${colors.WHITE}; + border-left-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-left .rc-tooltip-arrow { @@ -140,7 +141,7 @@ const Wrapper = styled.div` top: -5px; margin-left: -6px; border-width: 0 6px 6px; - border-bottom-color: ${colors.DIVIDER}; + border-bottom-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-bottom .rc-tooltip-arrow-inner, @@ -149,7 +150,7 @@ const Wrapper = styled.div` top: 1px; margin-left: -6px; border-width: 0 6px 6px; - border-bottom-color: ${colors.WHITE}; + border-bottom-color: ${colors.TOOLTIP_BACKGROUND}; } .rc-tooltip-placement-bottom .rc-tooltip-arrow { @@ -165,6 +166,20 @@ const Wrapper = styled.div` } `; +const Content = styled.div` + padding-bottom: 10px; + text-align: justify; +`; + +const ContentWrapper = styled.div``; +const ReadMore = styled.div` + padding: 10px 0 5px 0; + text-align: center; + width: 100%; + color: ${colors.WHITE}; + border-top: 1px solid ${colors.TEXT_SECONDARY}; +`; + class Tooltip extends Component { render() { const { @@ -172,6 +187,7 @@ class Tooltip extends Component { placement, content, children, + readMoreLink, maxWidth, } = this.props; return ( @@ -184,7 +200,16 @@ class Tooltip extends Component { getTooltipContainer={() => this.tooltipContainerRef} arrowContent={

} placement={placement} - overlay={content} + overlay={() => ( + + {content} + {readMoreLink && ( + + Read more + + ) + } + )} > {children} @@ -205,6 +230,7 @@ Tooltip.propTypes = { PropTypes.element, PropTypes.string, ]), + readMoreLink: PropTypes.string, }; export default Tooltip; diff --git a/src/components/inputs/Input/index.js b/src/components/inputs/Input/index.js index 0b3e2e16..c63a7351 100644 --- a/src/components/inputs/Input/index.js +++ b/src/components/inputs/Input/index.js @@ -115,20 +115,21 @@ class Input extends Component { /> )} 0} innerRef={this.props.innerRef} hasAddon={!!this.props.sideAddons} type={this.props.type} color={this.getColor(this.props.state)} placeholder={this.props.placeholder} - autoComplete={this.props.autoComplete} - autoCorrect={this.props.autoCorrect} - autoCapitalize={this.props.autoCapitalize} + autocomplete={this.props.autocomplete} + autocorrect={this.props.autocorrect} + autocapitalize={this.props.autocapitalize} spellCheck={this.props.spellCheck} value={this.props.value} onChange={this.props.onChange} borderColor={this.getColor(this.props.state)} disabled={this.props.isDisabled} + name={this.props.name} /> {this.props.sideAddons && this.props.sideAddons.map(sideAddon => sideAddon)} @@ -150,9 +151,9 @@ Input.propTypes = { innerRef: PropTypes.func, placeholder: PropTypes.string, type: PropTypes.string, - autoComplete: PropTypes.string, - autoCorrect: PropTypes.string, - autoCapitalize: PropTypes.string, + autocomplete: PropTypes.string, + autocorrect: PropTypes.string, + autocapitalize: PropTypes.string, spellCheck: PropTypes.string, value: PropTypes.string, onChange: PropTypes.func, @@ -161,6 +162,7 @@ Input.propTypes = { topLabel: PropTypes.node, sideAddons: PropTypes.arrayOf(PropTypes.node), isDisabled: PropTypes.bool, + name: PropTypes.string, }; Input.defaultProps = { diff --git a/src/components/modals/device/Duplicate/index.js b/src/components/modals/device/Duplicate/index.js index 9aeea355..89b2e0e2 100644 --- a/src/components/modals/device/Duplicate/index.js +++ b/src/components/modals/device/Duplicate/index.js @@ -63,12 +63,6 @@ const ErrorMessage = styled.div` `; export default class DuplicateDevice extends Component { - state: State; - - input: ?HTMLInputElement; - - keyboardHandler: (event: KeyboardEvent) => void; - constructor(props: Props) { super(props); @@ -85,6 +79,12 @@ export default class DuplicateDevice extends Component { }; } + state: State; + + input: ?HTMLInputElement; + + keyboardHandler: (event: KeyboardEvent) => void; + componentDidMount(): void { // one time autofocus if (this.input) this.input.focus(); diff --git a/src/components/modals/passphrase/Passphrase/index.js b/src/components/modals/passphrase/Passphrase/index.js index f9a5e1f3..d95f138c 100644 --- a/src/components/modals/passphrase/Passphrase/index.js +++ b/src/components/modals/passphrase/Passphrase/index.js @@ -1,18 +1,25 @@ /* @flow */ import React, { Component } from 'react'; -import raf from 'raf'; +import styled from 'styled-components'; import colors from 'config/colors'; +import { FONT_SIZE, TRANSITION } from 'config/variables'; import { H2 } from 'components/Heading'; import P from 'components/Paragraph'; -import { FONT_SIZE } from 'config/variables'; -import Link from 'components/Link'; import Checkbox from 'components/Checkbox'; import Button from 'components/Button'; import Input from 'components/inputs/Input'; -import styled from 'styled-components'; import type { Props } from '../../index'; +type State = { + deviceLabel: string, + shouldShowSingleInput: boolean, + passphraseInputValue: string, + passphraseCheckInputValue: string, + doPassphraseInputsMatch: boolean, + isPassphraseHidden: boolean, +}; + const Wrapper = styled.div` padding: 24px 48px; max-width: 390px; @@ -45,315 +52,229 @@ const Footer = styled.div` justify-content: center; `; -type State = { - deviceLabel: string; - singleInput: boolean; - passphrase: string; - passphraseRevision: string; - passphraseFocused: boolean; - passphraseRevisionFocused: boolean; - passphraseRevisionTouched: boolean; - match: boolean; - visible: boolean; -} +const LinkButton = styled(Button)` + padding: 0; + margin: 0; + text-decoration: none; + cursor: pointer; + transition: ${TRANSITION.HOVER}; + font-size: ${FONT_SIZE.SMALLER}; + border-radius: 0; + border-bottom: 1px solid ${colors.GREEN_PRIMARY}; + background: transparent; + + &, + &:visited, + &:active, + &:hover { + color: ${colors.GREEN_PRIMARY}; + } + + &:hover { + border-color: transparent; + background: transparent; + } +`; -export default class PinModal extends Component { +class Passphrase extends Component { constructor(props: Props) { super(props); const device = props.modal.opened ? props.modal.device : null; - if (!device) return; + if (!device) { + return; + } - // check if this device is already known - const selected = props.wallet.selectedDevice; + // Check if this device is already known + // if device is already known then only one input is presented + const { selectedDevice } = props.wallet; let deviceLabel = device.label; - let singleInput = false; - if (selected && selected.path === device.path) { - deviceLabel = selected.instanceLabel; - singleInput = selected.remember || selected.state !== null; + let shouldShowSingleInput = false; + if (selectedDevice && selectedDevice.path === device.path) { + deviceLabel = selectedDevice.instanceLabel; + shouldShowSingleInput = selectedDevice.remember || selectedDevice.state !== null; } this.state = { deviceLabel, - singleInput, - passphrase: '', - passphraseRevision: '', - passphraseFocused: false, - passphraseRevisionFocused: false, - passphraseRevisionTouched: false, - match: true, - visible: false, + shouldShowSingleInput, + passphraseInputValue: '', + passphraseCheckInputValue: '', + doPassphraseInputsMatch: true, + isPassphraseHidden: true, }; } - keyboardHandler: (event: KeyboardEvent) => void; - state: State; - passphraseInput: ?HTMLInputElement; - - passphraseRevisionInput: ?HTMLInputElement; - - - keyboardHandler(event: KeyboardEvent): void { - if (event.keyCode === 13) { - event.preventDefault(); - //this.passphraseInput.blur(); - //this.passphraseRevisionInput.blur(); - - //this.passphraseInput.type = 'text'; - //this.passphraseRevisionInput.type = 'text'; - - this.submit(); - - // TODO: set timeout, or wait for blur event - //onPassphraseSubmit(passphrase, passphraseCached); - //raf(() => onPassphraseSubmit(passphrase)); - } - } - - - componentDidMount(): void { - // one time autofocus - if (this.passphraseInput) this.passphraseInput.focus(); - this.keyboardHandler = this.keyboardHandler.bind(this); - window.addEventListener('keydown', this.keyboardHandler, false); - + componentDidMount() { + this.passphraseInput.focus(); - // document.oncontextmenu = (event) => { - // const el = window.event.srcElement || event.target; - // const type = el.tagName.toLowerCase() || ''; - // if (type === 'input') { - // return false; - // } - // }; + this.handleKeyPress = this.handleKeyPress.bind(this); + window.addEventListener('keypress', this.handleKeyPress, false); } - // we don't want to keep password inside "value" attribute, - // so we need to replace it thru javascript - componentDidUpdate() { - const { - passphrase, - passphraseRevision, - passphraseFocused, - passphraseRevisionFocused, - visible, - } = this.state; - // } = this.props.modal; - - 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.setAttribute('type', visible ? 'text' : 'password'); - } - if (this.passphraseRevisionInput) { - // this.passphraseRevisionInput.value = passphraseRevisionInputValue; - // this.passphraseRevisionInput.setAttribute('type', visible || (!visible && !passphraseRevisionFocused) ? 'text' : 'password'); - this.passphraseRevisionInput.setAttribute('type', visible ? 'text' : 'password'); - } - } - - componentWillUnmount(): void { - window.removeEventListener('keydown', this.keyboardHandler, false); - // this.passphraseInput.type = 'text'; - // this.passphraseInput.style.display = 'none'; - // this.passphraseRevisionInput.type = 'text'; - // this.passphraseRevisionInput.style.display = 'none'; + componentWillUnmount() { + window.removeEventListener('keypress', this.handleKeyPress, false); } + handleKeyPress: (event: KeyboardEvent) => void; + + passphraseInput: HTMLInputElement; + + handleInputChange(event: Event) { + const { target } = event; + if (target instanceof HTMLInputElement) { + const inputValue = target.value; + const inputName = target.name; + + let doPassphraseInputsMatch = false; + if (inputName === 'passphraseInputValue') { + // If passphrase is not hidden the second input should get filled automatically + // and should be disabled + if (this.state.isPassphraseHidden) { + doPassphraseInputsMatch = inputValue === this.state.passphraseCheckInputValue; + } else { + // Since both inputs are same they really do match + // see: comment above + this.setState({ + passphraseCheckInputValue: inputValue, + }); + doPassphraseInputsMatch = true; + } + } else if (inputName === 'passphraseCheckInputValue') { + doPassphraseInputsMatch = inputValue === this.state.passphraseInputValue; + } - onPassphraseChange = (input: string, value: string): void => { - // https://codepen.io/MiDri/pen/PGqvrO - // or - // https://github.com/zakangelle/react-password-mask/blob/master/src/index.js - if (input === 'passphrase') { - this.setState(previousState => ({ - match: previousState.singleInput || previousState.passphraseRevision === value, - passphrase: value, - })); - - if (this.state.visible && this.passphraseRevisionInput) { - this.setState({ - match: true, - passphraseRevision: value, - }); - this.passphraseRevisionInput.value = value; + if (this.state.shouldShowSingleInput) { + doPassphraseInputsMatch = true; } - } else { - this.setState(previousState => ({ - match: previousState.passphrase === value, - passphraseRevision: value, - passphraseRevisionTouched: true, - })); - } - } - onPassphraseFocus = (input: string): void => { - if (input === 'passphrase') { this.setState({ - passphraseFocused: true, - }); - } else { - this.setState({ - passphraseRevisionFocused: true, + [inputName]: inputValue, + doPassphraseInputsMatch, }); } } - onPassphraseBlur = (input: string): void => { - if (input === 'passphrase') { - this.setState({ - passphraseFocused: false, - }); + handleCheckboxClick() { + // If passphrase was visible and now shouldn't be --> delete the value of passphraseCheckInputValue + // doPassphraseInputsMatch + // - if passphrase was visible and now shouldn't be --> doPassphraseInputsMatch = false + // - because passphraseCheckInputValue will be empty string + // - if passphrase wasn't visibe and now should be --> doPassphraseInputsMatch = true + // - because passphraseCheckInputValue will be same as passphraseInputValue + let doInputsMatch = false; + if (this.state.shouldShowSingleInput || this.state.passphraseInputValue === this.state.passphraseCheckInputValue) { + doInputsMatch = true; } else { - this.setState({ - passphraseRevisionFocused: false, - }); - } - } - - onPassphraseShow = (): void => { - 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, - })); + doInputsMatch = !!this.state.isPassphraseHidden; } - } - 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; - } + this.setState(previousState => ({ + isPassphraseHidden: !previousState.isPassphraseHidden, + passphraseInputValue: previousState.isPassphraseHidden ? previousState.passphraseInputValue : '', + passphraseCheckInputValue: previousState.isPassphraseHidden ? previousState.passphraseInputValue : '', + doPassphraseInputsMatch: doInputsMatch, + })); } - submit = (empty: boolean = false): void => { + submitPassphrase(shouldLeavePassphraseBlank: boolean = false) { const { onPassphraseSubmit } = this.props.modalActions; - const { passphrase, match } = this.state; - - if (!match) return; - - //this.passphraseInput.type = 'text'; - // this.passphraseInput.style.display = 'none'; - //this.passphraseInput.setAttribute('readonly', 'readonly'); - // this.passphraseRevisionInput.type = 'text'; - //this.passphraseRevisionInput.style.display = 'none'; - //this.passphraseRevisionInput.setAttribute('readonly', 'readonly'); - - // const p = passphrase; + const passphrase = this.state.passphraseInputValue; + // Reset state so same passphrase isn't filled when the modal will be visible again this.setState({ - passphrase: '', - passphraseRevision: '', - passphraseFocused: false, - passphraseRevisionFocused: false, - visible: false, + passphraseInputValue: '', + passphraseCheckInputValue: '', + doPassphraseInputsMatch: true, + isPassphraseHidden: true, }); - raf(() => onPassphraseSubmit(empty ? '' : passphrase)); + onPassphraseSubmit(shouldLeavePassphraseBlank ? '' : passphrase); } - render() { - if (!this.props.modal.opened) return null; + handleKeyPress(event: KeyboardEvent) { + if (event.key === 'Enter') { + event.preventDefault(); + console.warn('ENTER', this.state); + if (this.state.doPassphraseInputsMatch) { + this.submitPassphrase(); + } + } + } - const { - device, - } = this.props.modal; + render() { + if (!this.props.modal.opened) { + return null; + } - const { - deviceLabel, - singleInput, - passphrase, - passphraseRevision, - passphraseFocused, - passphraseRevisionFocused, - visible, - match, - passphraseRevisionTouched, - } = this.state; - - let passphraseInputType: string = visible || (!visible && !passphraseFocused) ? 'text' : 'password'; - let passphraseRevisionInputType: string = visible || (!visible && !passphraseRevisionFocused) ? 'text' : 'password'; - passphraseInputType = passphraseRevisionInputType = 'text'; - //let passphraseInputType: string = visible || passphraseFocused ? "text" : "password"; - //let passphraseRevisionInputType: string = visible || passphraseRevisionFocused ? "text" : "password"; - - const showPassphraseCheckboxFn: Function = visible ? this.onPassphraseHide : this.onPassphraseShow; return ( -

Enter { deviceLabel } passphrase

+

Enter {this.state.deviceLabel} passphrase

Note that passphrase is case-sensitive.

{ this.passphraseInput = element; }} - onChange={event => this.onPassphraseChange('passphrase', event.currentTarget.value)} - type={passphraseInputType} - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - data-lpignore="true" - onFocus={() => this.onPassphraseFocus('passphrase')} - onBlur={() => this.onPassphraseBlur('passphrase')} - tabIndex="0" + innerRef={(input) => { this.passphraseInput = input; }} + name="passphraseInputValue" + type={this.state.isPassphraseHidden ? 'password' : 'text'} + autocorrect="off" + autocapitalize="off" + autocomplete="off" + value={this.state.passphraseInputValue} + onChange={event => this.handleInputChange(event)} /> - {!singleInput && ( + {!this.state.shouldShowSingleInput && ( { this.passphraseRevisionInput = element; }} - onChange={event => this.onPassphraseChange('revision', event.currentTarget.value)} - type={passphraseRevisionInputType} - autoComplete="off" - autoCorrect="off" - autoCapitalize="off" - spellCheck="false" - data-lpignore="true" - onFocus={() => this.onPassphraseFocus('revision')} - onBlur={() => this.onPassphraseBlur('revision')} + name="passphraseCheckInputValue" + type={this.state.isPassphraseHidden ? 'password' : 'text'} + autocorrect="off" + autocapitalize="off" + autocomplete="off" + value={this.state.passphraseCheckInputValue} + onChange={event => this.handleInputChange(event)} + isDisabled={!this.state.isPassphraseHidden} /> - {!match && passphraseRevisionTouched && Passphrases do not match } - ) } + )} + + {!this.state.doPassphraseInputsMatch && ( + Passphrases do not match + )} + - Show passphrase + this.handleCheckboxClick()} + > + Show passphrase + - + +

If you want to access your default account

- this.submit(true)}>Leave passphrase blank + this.submitPassphrase(true)} + >Leave passphrase blank +

); } -} \ No newline at end of file +} + +export default Passphrase; \ No newline at end of file diff --git a/src/components/notifications/App/Container.js b/src/components/notifications/App/Container.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/notifications/App/index.js b/src/components/notifications/App/index.js new file mode 100644 index 00000000..e69de29b diff --git a/src/components/notifications/Context/components/Account/index.js b/src/components/notifications/Context/components/Account/index.js new file mode 100644 index 00000000..fe42bbd1 --- /dev/null +++ b/src/components/notifications/Context/components/Account/index.js @@ -0,0 +1,36 @@ +/* @flow */ +import * as React from 'react'; +import { Notification } from 'components/Notification'; + +import type { Props } from '../../index'; + +// There could be only one account notification +export default (props: Props) => { + const { notification } = props.selectedAccount; + if (notification) { + if (notification.type === 'backend') { + // special case: backend is down + // TODO: this is a different component with "auto resolve" button + + return ( + { + await props.blockchainReconnect('trop'); + }, + }] + } + /> + ); + + // return (); + } + return (); + } + return null; +}; \ No newline at end of file diff --git a/src/components/notifications/Context/components/Action/index.js b/src/components/notifications/Context/components/Action/index.js new file mode 100644 index 00000000..0bf7f0be --- /dev/null +++ b/src/components/notifications/Context/components/Action/index.js @@ -0,0 +1,20 @@ +/* @flow */ +import * as React from 'react'; +import { Notification } from 'components/Notification'; + +import type { Props } from '../../index'; + +export default (props: Props) => { + const { notifications, close } = props; + return notifications.map(n => ( + + )); +}; \ No newline at end of file diff --git a/src/components/notifications/Context/components/Static/index.js b/src/components/notifications/Context/components/Static/index.js new file mode 100644 index 00000000..47d30b24 --- /dev/null +++ b/src/components/notifications/Context/components/Static/index.js @@ -0,0 +1,17 @@ +/* @flow */ +import * as React from 'react'; +import { Notification } from 'components/Notification'; + +import type { Props } from '../../index'; + +export default (props: Props) => { + const { location } = props.router; + if (!location) return null; + + const notifications: Array = []; + // if (location.state.device) { + // notifications.push(); + // } + + return notifications; +}; \ No newline at end of file diff --git a/src/components/notifications/Context/index.js b/src/components/notifications/Context/index.js new file mode 100644 index 00000000..e28bba0b --- /dev/null +++ b/src/components/notifications/Context/index.js @@ -0,0 +1,55 @@ +/* @flow */ +import * as React from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; + +import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; +import type { State, Dispatch } from 'flowtype'; + +import { reconnect } from 'actions/DiscoveryActions'; +import * as NotificationActions from 'actions/NotificationActions'; + +import StaticNotifications from './components/Static'; +import AccountNotifications from './components/Account'; +import ActionNotifications from './components/Action'; + +export type StateProps = { + router: $ElementType; + notifications: $ElementType; + selectedAccount: $ElementType; + wallet: $ElementType; + blockchain: $ElementType; + children?: React.Node; +} + +export type DispatchProps = { + close: typeof NotificationActions.close; + blockchainReconnect: typeof reconnect; +} + +export type Props = StateProps & DispatchProps; + +type OwnProps = {}; + +const Notifications = (props: Props) => ( + + + + + +); + +const mapStateToProps: MapStateToProps = (state: State): StateProps => ({ + router: state.router, + notifications: state.notifications, + selectedAccount: state.selectedAccount, + wallet: state.wallet, + blockchain: state.blockchain, +}); + +const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ + close: bindActionCreators(NotificationActions.close, dispatch), + blockchainReconnect: bindActionCreators(reconnect, dispatch), +}); + +export default connect(mapStateToProps, mapDispatchToProps)(Notifications); \ No newline at end of file diff --git a/src/config/colors.js b/src/config/colors.js index c9eb2cca..e870decc 100644 --- a/src/config/colors.js +++ b/src/config/colors.js @@ -32,4 +32,5 @@ export default { ERROR_SECONDARY: '#FFE9E9', LABEL_COLOR: '#A9A9A9', + TOOLTIP_BACKGROUND: '#333333', }; \ No newline at end of file diff --git a/src/constants/notifications.js b/src/constants/notifications.js new file mode 100644 index 00000000..1ffed4c1 --- /dev/null +++ b/src/constants/notifications.js @@ -0,0 +1,8 @@ +export default { + PRIORITY: { + error: 0, + warning: 1, + info: 2, + success: 3, + }, +}; \ No newline at end of file diff --git a/src/index.html b/src/index.html index c62bd67d..348f47f1 100644 --- a/src/index.html +++ b/src/index.html @@ -10,8 +10,29 @@ + +
diff --git a/src/reducers/DevicesReducer.js b/src/reducers/DevicesReducer.js index f75d991d..538c096a 100644 --- a/src/reducers/DevicesReducer.js +++ b/src/reducers/DevicesReducer.js @@ -94,7 +94,6 @@ const addDevice = (state: State, device: Device): State => { const newDevice: TrezorDevice = device.type === 'acquired' ? { ...device, - // acquiring: false, ...extended, } : { ...device, @@ -132,8 +131,8 @@ const addDevice = (state: State, device: Device): State => { const changedDevices: Array = affectedDevices.filter(d => d.features && device.features && d.features.passphrase_protection === device.features.passphrase_protection).map((d) => { - const extended: Object = { connected: true, available: true }; - return mergeDevices(d, { ...device, ...extended }); + const extended2: Object = { connected: true, available: true }; + return mergeDevices(d, { ...device, ...extended2 }); }); if (changedDevices.length !== affectedDevices.length) { changedDevices.push(newDevice); @@ -154,7 +153,6 @@ const duplicate = (state: State, device: TrezorDevice): State => { const newDevice: TrezorDevice = { ...device, - // acquiring: false, remember: false, state: null, // instance, (instance is already part of device - added in modal) @@ -199,7 +197,7 @@ const authDevice = (state: State, device: TrezorDevice, deviceState: string): St // Transform JSON form local storage into State -const devicesFromStorage = (devices: Array): State => devices.map((device: TrezorDevice) => { +const devicesFromStorage = ($devices: Array): State => $devices.map((device: TrezorDevice) => { const extended = { connected: false, available: false, @@ -233,14 +231,14 @@ const disconnectDevice = (state: State, device: Device): State => { const otherDevices: State = state.filter(d => affectedDevices.indexOf(d) === -1); if (affectedDevices.length > 0) { - const acquiredDevices = affectedDevices.filter(d => d.features && d.state).map((d) => { - if (d.type === 'acquired') { - d.connected = false; - d.available = false; - d.status = 'available'; - d.path = ''; - } - return d; + const acquiredDevices = affectedDevices.filter(d => d.features && d.state).map((d) => { // eslint-disable-line arrow-body-style + return d.type === 'acquired' ? { + ...d, + connected: false, + available: false, + status: 'available', + path: '', + } : d; }); return otherDevices.concat(acquiredDevices); } @@ -249,12 +247,18 @@ const disconnectDevice = (state: State, device: Device): State => { }; const onSelectedDevice = (state: State, device: ?TrezorDevice): State => { - if (device) { - const otherDevices: Array = state.filter(d => d !== device); - device.ts = new Date().getTime(); - return otherDevices.concat([device]); - } - return state; + if (!device) return state; + + const otherDevices: Array = state.filter(d => d !== device); + const extended = device.type === 'acquired' ? { + ...device, + ts: new Date().getTime(), + } : { + ...device, + features: null, + ts: new Date().getTime(), + }; + return otherDevices.concat([extended]); }; export default function devices(state: State = initialState, action: Action): State { diff --git a/src/reducers/PendingTxReducer.js b/src/reducers/PendingTxReducer.js index 9f4ab635..f89243ea 100644 --- a/src/reducers/PendingTxReducer.js +++ b/src/reducers/PendingTxReducer.js @@ -65,7 +65,7 @@ export default function pending(state: State = initialState, action: Action): St // return add(state, action.payload); case PENDING.TX_RESOLVED: return remove(state, action.tx.id); - case PENDING.TX_NOT_FOUND: + case PENDING.TX_REJECTED: return reject(state, action.tx.id); case PENDING.FROM_STORAGE: diff --git a/src/reducers/SelectedAccountReducer.js b/src/reducers/SelectedAccountReducer.js index 68fb56d6..1b400668 100644 --- a/src/reducers/SelectedAccountReducer.js +++ b/src/reducers/SelectedAccountReducer.js @@ -11,12 +11,18 @@ import type { } from 'flowtype'; export type State = { - location?: string; + location: string; account: ?Account; network: ?Coin; tokens: Array, pending: Array, - discovery: ?Discovery + discovery: ?Discovery, + notification: ?{ + type: string, + title: string, + message?: string, + }, + shouldRender: boolean, }; export const initialState: State = { @@ -26,6 +32,8 @@ export const initialState: State = { tokens: [], pending: [], discovery: null, + notification: null, + shouldRender: false, }; export default (state: State = initialState, action: Action): State => { diff --git a/src/reducers/TrezorConnectReducer.js b/src/reducers/TrezorConnectReducer.js index f6e48630..f8adf4dd 100644 --- a/src/reducers/TrezorConnectReducer.js +++ b/src/reducers/TrezorConnectReducer.js @@ -10,9 +10,7 @@ export type SelectedDevice = { } export type State = { - // devices: Array; - // selectedDevice: ?SelectedDevice; - discoveryComplete: boolean; + initialized: boolean; error: ?string; transport: ?{ type: string; @@ -26,54 +24,42 @@ export type State = { // mobile: boolean; // } | {}; browserState: any; - acquiring: boolean; + acquiringDevice: boolean; } - const initialState: State = { - // devices: [], - //selectedDevice: null, - discoveryComplete: false, + initialized: false, error: null, transport: null, browserState: {}, - acquiring: false, + acquiringDevice: false, }; export default function connect(state: State = initialState, action: Action): State { switch (action.type) { - case UI.IFRAME_HANDSHAKE: - return { - ...state, - browserState: action.payload.browser, - }; - - case CONNECT.START_ACQUIRING: - return { - ...state, - acquiring: true, - }; - - case CONNECT.STOP_ACQUIRING: + // trezor-connect iframe didn't loaded properly + case CONNECT.INITIALIZATION_ERROR: return { ...state, - acquiring: false, + error: action.error, }; - - case CONNECT.INITIALIZATION_ERROR: + // trezor-connect iframe loaded + case UI.IFRAME_HANDSHAKE: return { ...state, - error: action.error, + initialized: true, + browserState: action.payload.browser, }; - + // trezor-connect (trezor-link) initialized case TRANSPORT.START: return { ...state, transport: action.payload, error: null, }; - + // trezor-connect (trezor-link) + // will be called continuously in interval until connection (bridge/webusb) will be established case TRANSPORT.ERROR: return { ...state, @@ -82,6 +68,17 @@ export default function connect(state: State = initialState, action: Action): St transport: null, }; + case CONNECT.START_ACQUIRING: + return { + ...state, + acquiringDevice: true, + }; + + case CONNECT.STOP_ACQUIRING: + return { + ...state, + acquiringDevice: false, + }; default: return state; diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index 9b5ba7f5..d3a8e740 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -116,21 +116,58 @@ export const getWeb3 = (state: State): ?Web3Instance => { return state.web3.find(w3 => w3.network === locationState.network); }; -export const observeChanges = (prev: ?(Object | Array), current: ?(Object | Array), fields?: Array): boolean => { - if (prev !== current) { - // 1. one of the objects is null/undefined - if (!prev || !current) return true; - // 2. object are Arrays and they have different length - if (Array.isArray(prev) && Array.isArray(current)) return prev.length !== current.length; - // 3. no nested field to check - if (!Array.isArray(fields)) return true; - // 4. validate nested field - if (prev instanceof Object && current instanceof Object) { - for (let i = 0; i < fields.length; i++) { - const key = fields[i]; - if (prev[key] !== current[key]) return true; +export const observeChanges = (prev: ?Object, current: ?Object, filter?: {[k: string]: Array}): boolean => { + // 1. both objects are the same (solves simple types like string, boolean and number) + if (prev === current) return false; + // 2. one of the objects is null/undefined + if (!prev || !current) return true; + + const prevType = Object.prototype.toString.call(current); + const currentType = Object.prototype.toString.call(current); + // 3. one of the objects has different type then other + if (prevType !== currentType) return true; + + if (currentType === '[object Array]') { + // 4. Array length is different + if (prev.length !== current.length) return true; + // observe array recursive + for (let i = 0; i < current.length; i++) { + if (observeChanges(prev[i], current[i], filter)) return true; + } + } else if (currentType === '[object Object]') { + const prevKeys = Object.keys(prev); + const currentKeys = Object.keys(prev); + // 5. simple validation of keys length + if (prevKeys.length !== currentKeys.length) return true; + + // 6. "prev" has keys which "current" doesn't have + const prevDifference = prevKeys.find(k => currentKeys.indexOf(k) < 0); + if (prevDifference) return true; + + // 7. "current" has keys which "prev" doesn't have + const currentDifference = currentKeys.find(k => prevKeys.indexOf(k) < 0); + if (currentDifference) return true; + + // 8. observe every key recursive + for (let i = 0; i < currentKeys.length; i++) { + const key = currentKeys[i]; + if (filter && filter.hasOwnProperty(key) && prev[key] && current[key]) { + const prevFiltered = {}; + const currentFiltered = {}; + for (let i2 = 0; i2 < filter[key].length; i2++) { + const field = filter[key][i2]; + prevFiltered[field] = prev[key][field]; + currentFiltered[field] = current[key][field]; + } + if (observeChanges(prevFiltered, currentFiltered)) return true; + } else if (observeChanges(prev[key], current[key])) { + return true; } } + } else if (prev !== current) { + // solve simple types like string, boolean and number + return true; } + return false; }; diff --git a/src/services/LocalStorageService.js b/src/services/LocalStorageService.js index 8bc66798..9a9af858 100644 --- a/src/services/LocalStorageService.js +++ b/src/services/LocalStorageService.js @@ -110,9 +110,12 @@ const LocalStorageService: Middleware = (api: MiddlewareAPI) => (next: Middlewar case SEND.TX_COMPLETE: case PENDING.TX_RESOLVED: - case PENDING.TX_NOT_FOUND: + case PENDING.TX_REJECTED: save(api.dispatch, api.getState); break; + + default: + return action; } return action; diff --git a/src/services/WalletService.js b/src/services/WalletService.js index 8856b0c9..76db7638 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -5,7 +5,6 @@ import * as WALLET from 'actions/constants/wallet'; import * as CONNECT from 'actions/constants/TrezorConnect'; import * as WalletActions from 'actions/WalletActions'; -import * as RouterActions from 'actions/RouterActions'; import * as NotificationActions from 'actions/NotificationActions'; import * as LocalStorageActions from 'actions/LocalStorageActions'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; @@ -22,7 +21,7 @@ import type { /** * Middleware */ -const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => (action: Action): Action => { +const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => async (action: Action): Promise => { const prevState = api.getState(); // Application live cycle starts HERE! @@ -52,9 +51,6 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa if (action.device) { // try to authorize device api.dispatch(TrezorConnectActions.getSelectedDeviceState()); - } else { - // try select different device - api.dispatch(RouterActions.selectFirstAvailableDevice()); } break; case DEVICE.CONNECT: @@ -92,14 +88,14 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state)); } - // observe send form props changes - api.dispatch(SendFormActionActions.observe(prevState, action)); - - // update common values in WallerReducer - api.dispatch(WalletActions.updateSelectedValues(prevState, action)); - - // update common values in SelectedAccountReducer - api.dispatch(SelectedAccountActions.updateSelectedValues(prevState, action)); + // observe common values in WallerReducer + if (!await api.dispatch(WalletActions.observe(prevState, action))) { + // if "selectedDevice" didn't change observe common values in SelectedAccountReducer + if (!await api.dispatch(SelectedAccountActions.observe(prevState, action))) { + // if "selectedAccount" didn't change observe send form props changes + api.dispatch(SendFormActionActions.observe(prevState, action)); + } + } return action; }; diff --git a/src/support/BaseStyles.js b/src/support/BaseStyles.js index d6100a2a..354d4960 100644 --- a/src/support/BaseStyles.js +++ b/src/support/BaseStyles.js @@ -41,6 +41,10 @@ const baseStyles = () => injectGlobal` outline: 0; } + #root { + height: 100%; + } + /* custom Roboto with Zero without the thing inside, so it's more readable as number since 0 doesn't look too similar to 8 diff --git a/src/utils/notification.js b/src/utils/notification.js new file mode 100644 index 00000000..7567a542 --- /dev/null +++ b/src/utils/notification.js @@ -0,0 +1,31 @@ +import colors from 'config/colors'; +import icons from 'config/icons'; + +const getColor = (type) => { + let color; + switch (type) { + case 'info': + color = colors.INFO_PRIMARY; + break; + case 'error': + color = colors.ERROR_PRIMARY; + break; + case 'warning': + color = colors.WARNING_PRIMARY; + break; + case 'success': + color = colors.SUCCESS_PRIMARY; + break; + default: + color = null; + } + + return color; +}; + +const getIcon = type => icons[type.toUpperCase()]; + +export { + getColor, + getIcon, +}; \ No newline at end of file diff --git a/src/utils/windowUtils.js b/src/utils/windowUtils.js index f7e37334..c13c803b 100644 --- a/src/utils/windowUtils.js +++ b/src/utils/windowUtils.js @@ -3,9 +3,19 @@ export const getViewportHeight = (): number => ( // $FlowIssue - window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight // $FlowIssue + document.documentElement.clientHeight || document.body.clientHeight // $FlowIssue ); +export const getScrollX = (): number => { + if (window.pageXOffset !== undefined) { + return window.pageXOffset; + } if (window.scrollLeft !== undefined) { + return window.scrollLeft; + } + // $FlowIssue + return (document.documentElement || document.body.parentNode || document.body).scrollLeft; // $FlowIssue +}; + export const getScrollY = (): number => { if (window.pageYOffset !== undefined) { return window.pageYOffset; diff --git a/src/views/Landing/components/InitializationError/index.js b/src/views/Landing/components/InitializationError/index.js new file mode 100644 index 00000000..2db65aee --- /dev/null +++ b/src/views/Landing/components/InitializationError/index.js @@ -0,0 +1,23 @@ +/* @flow */ + +import React from 'react'; +import styled from 'styled-components'; +import { Notification } from 'components/Notification'; + +const Wrapper = styled.div` + min-width: 720px; + width: 100%; +`; + +const InitializationError = (props: { error: ?string }) => ( + + + +); + +export default InitializationError; \ No newline at end of file diff --git a/src/views/Landing/index.js b/src/views/Landing/index.js index 3da5dd4a..13ec9331 100644 --- a/src/views/Landing/index.js +++ b/src/views/Landing/index.js @@ -14,6 +14,7 @@ import { H2 } from 'components/Heading'; import { isWebUSB } from 'utils/device'; import { FONT_SIZE } from 'config/variables'; +import InitializationError from './components/InitializationError'; import BrowserNotSupported from './components/BrowserNotSupported'; import ConnectDevice from './components/ConnectDevice'; import InstallBridge from './components/InstallBridge'; @@ -21,7 +22,7 @@ import InstallBridge from './components/InstallBridge'; import type { Props } from './Container'; const LandingWrapper = styled.div` - min-height: 100vh; + min-height: 100%; min-width: 720px; display: flex; @@ -77,13 +78,13 @@ export default (props: Props) => { const bridgeRoute: boolean = props.router.location.state.hasOwnProperty('bridge'); const deviceLabel = props.wallet.disconnectRequest ? props.wallet.disconnectRequest.label : ''; - const shouldShowInstallBridge = connectError || bridgeRoute; + const shouldShowInitializationError = connectError && !props.connect.initialized; + const shouldShowInstallBridge = props.connect.initialized && (connectError || bridgeRoute); const shouldShowConnectDevice = props.wallet.ready && devices.length < 1; const shouldShowDisconnectDevice = !!props.wallet.disconnectRequest; const shouldShowUnsupportedBrowser = browserState.supported === false; - const isLoading = !shouldShowInstallBridge && !shouldShowConnectDevice && !shouldShowUnsupportedBrowser && !localStorageError; - + const isLoading = !shouldShowInitializationError && !shouldShowInstallBridge && !shouldShowConnectDevice && !shouldShowUnsupportedBrowser && !localStorageError; return ( {isLoading && } @@ -98,10 +99,9 @@ export default (props: Props) => { /> )} + {shouldShowInitializationError && } - - {shouldShowUnsupportedBrowser && } {shouldShowInstallBridge && } diff --git a/src/views/Wallet/components/LeftNavigation/Container.js b/src/views/Wallet/components/LeftNavigation/Container.js index 77b7514e..e297c59c 100644 --- a/src/views/Wallet/components/LeftNavigation/Container.js +++ b/src/views/Wallet/components/LeftNavigation/Container.js @@ -17,7 +17,7 @@ type OwnProps = { } -const mapStateToProps: MapStateToProps = (state: State/* , own: OwnProps */): StateProps => ({ +const mapStateToProps: MapStateToProps = (state: State): StateProps => ({ connect: state.connect, accounts: state.accounts, router: state.router, @@ -31,7 +31,6 @@ const mapStateToProps: MapStateToProps = (state: St }); const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ - //onAccountSelect: bindActionCreators(AccountActions.onAccountSelect, dispatch), toggleDeviceDropdown: bindActionCreators(toggleDeviceDropdown, dispatch), addAccount: bindActionCreators(TrezorConnectActions.addAccount, dispatch), acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch), diff --git a/src/views/Wallet/components/LeftNavigation/components/StickyContainer/index.js b/src/views/Wallet/components/LeftNavigation/components/StickyContainer/index.js index 65827a9a..6e983cbb 100644 --- a/src/views/Wallet/components/LeftNavigation/components/StickyContainer/index.js +++ b/src/views/Wallet/components/LeftNavigation/components/StickyContainer/index.js @@ -1,178 +1,181 @@ /* @flow */ - -// https://github.com/KyleAMathews/react-headroom/blob/master/src/shouldUpdate.js - import * as React from 'react'; import raf from 'raf'; -import { getViewportHeight, getScrollY } from 'utils/windowUtils'; +import { getViewportHeight, getScrollX, getScrollY } from 'utils/windowUtils'; import styled from 'styled-components'; import colors from 'config/colors'; type Props = { - location: string, - deviceSelection: boolean, children?: React.Node, } -const AsideWrapper = styled.aside` +type State = { + prevScrollY: number; + asideMinHeight: number, + wrapperTopOffset: number, + wrapperLeftOffset: number, + wrapperBottomPadding: number, + footerFixed: boolean, +} + +const AsideWrapper = styled.aside.attrs({ + style: ({ minHeight }) => ({ + minHeight, + }), +})` position: relative; - top: 0; + top: 0px; width: 320px; min-width: 320px; overflow: hidden; background: ${colors.MAIN}; - border-right: 1px solid ${colors.BACKGROUND}; - - .fixed { - border-right: 1px solid ${colors.BACKGROUND}; - position: fixed; - } - - .fixed-bottom { - padding-bottom: 60px; - .sticky-bottom { - position: fixed; - bottom: 0; - background: ${colors.MAIN}; - } - } + border-right: 1px solid ${colors.DIVIDER}; `; -const StickyContainerWrapper = styled.div` - position: relative; - top: 0; +const StickyContainerWrapper = styled.div.attrs({ + style: ({ top, left, paddingBottom }) => ({ + top, + left, + paddingBottom, + }), +})` + position: fixed; + border-right: 1px solid ${colors.DIVIDER}; width: 320px; overflow: hidden; `; -export default class StickyContainer extends React.PureComponent { - // Class variables. - currentScrollY: number = 0; +export default class StickyContainer extends React.PureComponent { + constructor() { + super(); + this.state = { + prevScrollY: 0, + asideMinHeight: 0, + wrapperTopOffset: 0, + wrapperLeftOffset: 0, + wrapperBottomPadding: 0, + footerFixed: false, + }; + } - lastKnownScrollY: number = 0; + componentDidMount() { + window.addEventListener('scroll', this.handleScroll); + window.addEventListener('resize', this.handleScroll); + this.update(); + } - topOffset: number = 0; + componentDidUpdate(prevProps: Props, newState: State) { + // recalculate view only if props was changed + // ignore when state is changed + if (this.state === newState) raf(this.update); + } - firstRender: boolean = false; + componentWillUnmount() { + window.removeEventListener('scroll', this.handleScroll); + window.removeEventListener('resize', this.handleScroll); + } - framePending: boolean = false; + update = () => { + this.recalculatePosition(); + } - stickToBottom: boolean = false; + handleScroll = () => raf(this.update); - top: number = 0; + asideRefCallback = (element: ?HTMLElement) => { + this.aside = element; + } - aside: ?HTMLElement; + wrapperRefCallback = (element: ?HTMLElement) => { + this.wrapper = element; + } - wrapper: ?HTMLElement; + footerRefCallback = (element: ?HTMLElement) => { + this.footer = element; + } - subscribers = []; + aside: ?HTMLElement; - // handleResize = (event: Event) => { + wrapper: ?HTMLElement; - // } + footer: ?HTMLElement; - shouldUpdate = () => { - const { wrapper, aside }: {wrapper: ?HTMLElement, aside: ?HTMLElement} = this; - if (!wrapper || !aside) return; - const bottom: ?HTMLElement = wrapper.querySelector('.sticky-bottom'); - if (!bottom) return; + recalculatePosition() { + const { aside, wrapper, footer } = this; + if (!aside || !wrapper || !footer) return; - const viewportHeight: number = getViewportHeight(); - const bottomBounds = bottom.getBoundingClientRect(); + const viewportHeight = getViewportHeight(); const asideBounds = aside.getBoundingClientRect(); const wrapperBounds = wrapper.getBoundingClientRect(); - const scrollDirection = this.currentScrollY >= this.lastKnownScrollY ? 'down' : 'up'; - const distanceScrolled = Math.abs(this.currentScrollY - this.lastKnownScrollY); - - if (asideBounds.top < 0) { - wrapper.classList.add('fixed'); - let maxTop: number = 1; - if (wrapperBounds.height > viewportHeight) { - const bottomOutOfBounds: boolean = (bottomBounds.bottom <= viewportHeight && scrollDirection === 'down'); + const footerBounds = footer.getBoundingClientRect(); + const isHeaderFixed = asideBounds.top < 0; + const isWrapperBiggerThanViewport = wrapperBounds.height > viewportHeight; + const state = { ...this.state }; + + const scrollX = getScrollX(); + const scrollY = getScrollY(); + + if (isHeaderFixed) { + if (isWrapperBiggerThanViewport) { + const scrollDirection = scrollY >= state.prevScrollY ? 'down' : 'up'; const topOutOfBounds: boolean = (wrapperBounds.top > 0 && scrollDirection === 'up'); - if (!bottomOutOfBounds && !topOutOfBounds) { - this.topOffset += scrollDirection === 'down' ? -distanceScrolled : distanceScrolled; + const bottomOutOfBounds: boolean = (footerBounds.bottom <= viewportHeight && scrollDirection === 'down'); + if (!topOutOfBounds && !bottomOutOfBounds) { + // neither "top" or "bottom" was reached + // scroll whole wrapper + const distanceScrolled = Math.abs(scrollY - state.prevScrollY); + state.wrapperTopOffset += scrollDirection === 'down' ? -distanceScrolled : distanceScrolled; } - maxTop = viewportHeight - wrapperBounds.height; } - - if (this.topOffset > 0) this.topOffset = 0; - if (maxTop < 0 && this.topOffset < maxTop) this.topOffset = maxTop; - wrapper.style.top = `${this.topOffset}px`; + // make sure that wrapper will not be over scrolled + if (state.wrapperTopOffset > 0) state.wrapperTopOffset = 0; } else { - wrapper.classList.remove('fixed'); - wrapper.style.top = '0px'; - this.topOffset = 0; + // update wrapper "top" to be same as "aside" element + state.wrapperTopOffset = asideBounds.top; } - if (wrapperBounds.height > viewportHeight) { - wrapper.classList.remove('fixed-bottom'); - } else if (wrapper.classList.contains('fixed-bottom')) { - if (bottomBounds.top < wrapperBounds.bottom - bottomBounds.height) { - wrapper.classList.remove('fixed-bottom'); + if (isWrapperBiggerThanViewport) { + state.footerFixed = false; + } else if (state.footerFixed) { + if (footerBounds.top < wrapperBounds.bottom - footerBounds.height) { + state.footerFixed = false; } - } else if (bottomBounds.bottom < viewportHeight) { - wrapper.classList.add('fixed-bottom'); + } else if (footerBounds.bottom < viewportHeight) { + state.footerFixed = true; } - aside.style.minHeight = `${wrapperBounds.height}px`; - } + state.prevScrollY = scrollY; + state.asideMinHeight = wrapperBounds.height; + state.wrapperBottomPadding = state.footerFixed ? footerBounds.height : 0; + // update wrapper "left" position + state.wrapperLeftOffset = scrollX > 0 ? -scrollX : asideBounds.left; - update = () => { - this.currentScrollY = getScrollY(); - this.shouldUpdate(); - this.framePending = false; - this.lastKnownScrollY = this.currentScrollY; - } - - componentDidMount() { - window.addEventListener('scroll', this.handleScroll); - window.addEventListener('resize', this.handleScroll); - raf(this.update); - } - - componentDidUpdate(prevProps: Props) { - if (this.props.location !== prevProps.location && this.aside) { - const asideBounds = this.aside.getBoundingClientRect(); - if (asideBounds.top < 0) { - window.scrollTo(0, getScrollY() + asideBounds.top); - this.topOffset = 0; - } - raf(this.update); - } else if (this.props.deviceSelection !== prevProps.deviceSelection) { - raf(this.update); - } else if (!this.firstRender) { - raf(this.update); - this.firstRender = true; - } - } - - componentWillUnmount() { - window.removeEventListener('scroll', this.handleScroll); - window.removeEventListener('resize', this.handleScroll); - } - - handleScroll = (/* event: ?Event */) => { - if (!this.framePending) { - this.framePending = true; - raf(this.update); - } + this.setState(state); } render() { return ( { this.aside = node; }} + footerFixed={this.state.footerFixed} + minHeight={this.state.asideMinHeight} + innerRef={this.asideRefCallback} onScroll={this.handleScroll} onTouchStart={this.handleScroll} onTouchMove={this.handleScroll} onTouchEnd={this.handleScroll} > { this.wrapper = node; }} + paddingBottom={this.state.wrapperBottomPadding} + top={this.state.wrapperTopOffset} + left={this.state.wrapperLeftOffset} + innerRef={this.wrapperRefCallback} > - {this.props.children} + {React.Children.map(this.props.children, (child) => { // eslint-disable-line arrow-body-style + return child.key === 'sticky-footer' ? React.cloneElement(child, { + innerRef: this.footerRefCallback, + position: this.state.footerFixed ? 'fixed' : 'relative', + }) : child; + })} ); diff --git a/src/views/Wallet/components/LeftNavigation/index.js b/src/views/Wallet/components/LeftNavigation/index.js index a91f2652..cc30ebe4 100644 --- a/src/views/Wallet/components/LeftNavigation/index.js +++ b/src/views/Wallet/components/LeftNavigation/index.js @@ -1,4 +1,6 @@ -import React, { Component } from 'react'; +/* @flow */ + +import * as React from 'react'; import PropTypes from 'prop-types'; import colors from 'config/colors'; import Icon from 'components/Icon'; @@ -10,6 +12,7 @@ import AccountMenu from './components/AccountMenu'; import CoinMenu from './components/CoinMenu'; import DeviceMenu from './components/DeviceMenu'; import StickyContainer from './components/StickyContainer'; +import type { Props } from './components/common'; const Header = styled(DeviceHeader)` border-right: 1px solid ${colors.BACKGROUND}; @@ -25,8 +28,11 @@ const TransitionContentWrapper = styled.div` vertical-align: top; `; -const Footer = styled.div` - position: relative; +const Footer = styled.div.attrs({ + style: ({ position }) => ({ + position, + }), +})` width: 320px; bottom: 0; background: ${colors.MAIN}; @@ -60,6 +66,13 @@ const A = styled.a` } `; +type TransitionMenuProps = { + animationType: ?string; + children?: React.Node; +} + +// TransitionMenu needs to dispatch window.resize event +// in order to StickyContainer be recalculated const TransitionMenu = (props: TransitionMenuProps): React$Element => ( ); -class LeftNavigation extends Component { - constructor(props) { +type State = { + animationType: ?string; + shouldRenderDeviceSelection: boolean; +} + +class LeftNavigation extends React.PureComponent { + constructor(props: Props) { super(props); + const { location } = this.props.router; + const hasNetwork = location && location.state && location.state.network; this.state = { - animationType: null, + animationType: hasNetwork ? 'slide-left' : null, shouldRenderDeviceSelection: false, }; } - componentDidMount() { - this.setState({ - animationType: null, - shouldRenderDeviceSelection: false, - }); - } - - componentWillReceiveProps(nextProps) { + componentWillReceiveProps(nextProps: Props) { const { deviceDropdownOpened } = nextProps; const { selectedDevice } = nextProps.wallet; - const hasNetwork = nextProps.location.state && nextProps.location.state.network; - const hasFeatures = selectedDevice && selectedDevice.features; - const deviceReady = hasFeatures && !selectedDevice.features.bootloader_mode && selectedDevice.features.initialized; + const hasNetwork = nextProps.router.location.state && nextProps.router.location.state.network; + const deviceReady = selectedDevice && selectedDevice.features && !selectedDevice.features.bootloader_mode && selectedDevice.features.initialized; if (deviceDropdownOpened) { this.setState({ shouldRenderDeviceSelection: true }); @@ -119,10 +131,11 @@ class LeftNavigation extends Component { shouldRenderAccounts() { const { selectedDevice } = this.props.wallet; + const { location } = this.props.router; return selectedDevice - && this.props.location - && this.props.location.state - && this.props.location.state.network + && location + && location.state + && location.state.network && !this.state.shouldRenderDeviceSelection && this.state.animationType === 'slide-left'; } @@ -132,7 +145,7 @@ class LeftNavigation extends Component { } shouldRenderCoins() { - return !this.state.shouldRenderDeviceSelection && this.state.animationType === 'slide-right'; + return !this.state.shouldRenderDeviceSelection && this.state.animationType !== 'slide-left'; } render() { @@ -154,7 +167,7 @@ class LeftNavigation extends Component { return (
} {menu} -