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/jest.config.js b/jest.config.js index d9c12451..10729508 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,7 +1,7 @@ module.exports = { rootDir: './src', automock: false, - coverageDirectory: 'coverage/', + coverageDirectory: '../coverage/', collectCoverage: true, testURL: 'http://localhost', modulePathIgnorePatterns: [ @@ -12,6 +12,7 @@ module.exports = { ], collectCoverageFrom: [ 'utils/**.js', + 'reducers/utils/**.js', ], setupFiles: [ './support/setupJest.js', 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 2dee9f5f..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()); } 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/Header/index.js b/src/components/Header/index.js index b64301c5..75a9862b 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; @@ -54,21 +60,25 @@ const A = styled.a` const Header = (): React$Element => ( - - - - - - - - - - + + + + + + + + + + + + + + TREZOR Docs Blog Support - + ); diff --git a/src/components/Notification/NotificationGroups/index.js b/src/components/Notification/NotificationGroups/index.js deleted file mode 100644 index 715b1da3..00000000 --- a/src/components/Notification/NotificationGroups/index.js +++ /dev/null @@ -1,56 +0,0 @@ -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 49953fff..4ffa29d1 100644 --- a/src/components/Notification/index.js +++ b/src/components/Notification/index.js @@ -1,6 +1,7 @@ /* @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'; @@ -37,6 +38,7 @@ const Wrapper = styled.div` background: ${colors.TEXT_SECONDARY}; padding: 24px 48px 24px 24px; display: flex; + min-height: 90px; flex-direction: row; text-align: left; @@ -59,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` @@ -85,28 +95,36 @@ 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 => { @@ -126,20 +144,20 @@ export const Notification = (props: NProps): React$Element => { )} - + - - { props.title } - { props.message && ( - -

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

+ + ) } + {props.actions && props.actions.length > 0 && ( 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/Notification/NotificationGroups/components/Group/index.js b/src/components/notifications/Context/components/Action/components/NotificationsGroups/components/Group/index.js similarity index 81% rename from src/components/Notification/NotificationGroups/components/Group/index.js rename to src/components/notifications/Context/components/Action/components/NotificationsGroups/components/Group/index.js index c3915b77..3edec964 100644 --- a/src/components/Notification/NotificationGroups/components/Group/index.js +++ b/src/components/notifications/Context/components/Action/components/NotificationsGroups/components/Group/index.js @@ -45,18 +45,18 @@ class Group extends Component { if (this.state.visible) { this.setState({ visible: false, - visibleCount: 0, + visibleCount: this.props.groupNotifications.length, }); } else { this.setState({ visible: true, - visibleCount: this.props.groupNotifications.length, + visibleCount: 0, }); } } render() { - const { type, groupNotifications } = this.props; + const { type, groupNotifications, close } = this.props; const color = getColor(type); return ( @@ -77,7 +77,7 @@ class Group extends Component { icon={ICONS.ARROW_DOWN} color={colors.TEXT_SECONDARY} size={24} - isActive={this.state.visible} + isActive={!this.state.visible} canAnimate /> @@ -88,10 +88,13 @@ class Group extends Component { .slice(0, this.state.visibleCount) .map(notification => ( ))} @@ -102,12 +105,15 @@ class Group extends Component { Group.propTypes = { type: PropTypes.string, - groupNotifications: PropTypes.arrayOf({ - key: PropTypes.string, - type: PropTypes.string, - title: PropTypes.string, - message: PropTypes.string, - }), + close: PropTypes.func.isRequired, + groupNotifications: PropTypes.arrayOf( + PropTypes.shape({ + key: PropTypes.number, + type: PropTypes.string, + title: PropTypes.string, + message: PropTypes.string, + }), + ), }; export default Group; \ No newline at end of file diff --git a/src/components/notifications/Context/components/Action/components/NotificationsGroups/index.js b/src/components/notifications/Context/components/Action/components/NotificationsGroups/index.js new file mode 100644 index 00000000..db61988c --- /dev/null +++ b/src/components/notifications/Context/components/Action/components/NotificationsGroups/index.js @@ -0,0 +1,88 @@ +import React, { Component } from 'react'; +import styled from 'styled-components'; +import PropTypes from 'prop-types'; + +import { PRIORITY } from 'constants/notifications'; +import Group from './components/Group'; + +const Wrapper = styled.div``; + +class NotificationsGroup extends Component { + 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 { close, notifications } = this.props; + // const notifications = [ + // { + // key: 1, + // title: 'this is a title of error notification', + // type: 'error', + // message: 'this is a message of error notification', + // }, + // { + // key: 2, + // title: 'this is a title of warning notification', + // type: 'warning', + // message: 'this is a message of warning notification', + // }, + // { + // key: 3, + // title: 'this is a title of warning notification', + // type: 'warning', + // message: 'this is a message of warning notification', + // }, + // { + // key: 4, + // title: 'this is a title of warning notification sds d', + // type: 'warning', + // message: 'this is a message of warning notification', + // }, + // { + // key: 5, + // title: 'this is a title of warning notification as', + // type: 'info', + // message: 'this is a message of warning notification', + // }, + // { + // key: 6, + // title: 'this is a title of info notification s ', + // type: 'info', + // message: 'this is a message of info notification', + // }, + // ]; + const notificationGroups = this.groupNotifications(notifications); + const sortedNotifications = this.sortByPriority(notificationGroups); + + return ( + + {Object.keys(sortedNotifications).map(group => ( + + ))} + + ); + } +} + +NotificationsGroup.propTypes = { + notifications: PropTypes.array.isRequired, + close: PropTypes.func.isRequired, +}; + +export default NotificationsGroup; \ 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..3050e159 --- /dev/null +++ b/src/components/notifications/Context/components/Action/index.js @@ -0,0 +1,15 @@ +/* @flow */ +import * as React from 'react'; +import NotificationsGroups from './components/NotificationsGroups'; + +import type { Props } from '../../index'; + +export default (props: Props) => { + const { notifications, close } = props; + return ( + + ); +}; \ 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/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/__tests__/__snapshots__/index.test.js.snap b/src/reducers/utils/__tests__/__snapshots__/index.test.js.snap new file mode 100644 index 00000000..5efb9857 --- /dev/null +++ b/src/reducers/utils/__tests__/__snapshots__/index.test.js.snap @@ -0,0 +1,55 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reducers utils observeChanges shoud be the same - returns false 1`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 2`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 3`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 4`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 5`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 6`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 7`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 8`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 9`] = `false`; + +exports[`reducers utils observeChanges shoud be the same - returns false 10`] = `false`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 1`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 2`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 3`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 4`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 5`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 6`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 7`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 8`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 9`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 10`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 11`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 12`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 13`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 14`] = `true`; + +exports[`reducers utils observeChanges shoul NOT be the same - returns true 15`] = `true`; + +exports[`reducers utils observeChanges test filter 1`] = `false`; + +exports[`reducers utils observeChanges test filter 2`] = `true`; diff --git a/src/reducers/utils/__tests__/index.test.js b/src/reducers/utils/__tests__/index.test.js new file mode 100644 index 00000000..48c5d93b --- /dev/null +++ b/src/reducers/utils/__tests__/index.test.js @@ -0,0 +1,148 @@ +import * as reducerUtils from '../index'; + +describe('reducers utils', () => { + it('observeChanges shoud be the same - returns false', () => { + const data = [ + // example of same data (false) + { + previous: {}, + current: {}, + }, + { + previous: 1, + current: 1, + }, + { + previous: [], + current: [], + }, + { + previous: [1, 1, 1], + current: [1, 1, 1], + }, + { + previous: 'a', + current: 'a', + }, + { + previous: { one: 1 }, + current: { one: 1 }, + }, + { + previous: { one: { two: 1 } }, + current: { one: { two: 1 } }, + }, + { + previous: { one: { two: [1, 2, 3] } }, + current: { one: { two: [1, 2, 3] } }, + }, + { + previous: { one: { two: [1, { three: 1 }, 3] } }, + current: { one: { two: [1, { three: 1 }, 3] } }, + }, + { + previous: { one: { two: [1, { three: 1 }, { four: 3, five: { six: 3 } }] } }, + current: { one: { two: [1, { three: 1 }, { four: 3, five: { six: 3 } }] } }, + }, + ]; + + data.forEach((item) => { + expect(reducerUtils.observeChanges( + item.previous, item.current, + )).toMatchSnapshot(); + }); + }); + + it('observeChanges shoul NOT be the same - returns true', () => { + const data = [ + // example of different data (true) + { + previous: null, + current: {}, + }, + { + previous: { one: 1 }, + current: {}, + }, + { + previous: { one: 1, three: 3 }, + current: { one: 1, two: 2 }, + }, + { + previous: [{}, {}], + current: [], + }, + { + previous: [1, 1, 1], + current: [1, 1], + }, + { + previous: 'a', + current: 'b', + }, + { + previous: ['a'], + current: ['b'], + }, + { + previous: 1, + current: '1', + }, + { + previous: { one: 1 }, + current: { one: 2 }, + }, + { + previous: { one: { two: 1 } }, + current: { one: { two: 2 } }, + }, + { + previous: { one: { two: 1 } }, + current: { one: { two: 2 } }, + }, + { + previous: { one: { two: [1, 2, 3] } }, + current: { one: { two: [1, 1, 3] } }, + }, + { + previous: { one: { two: [1, { three: 1 }, 3] } }, + current: { one: { two: [1, { three: 2 }, 3] } }, + }, + { + previous: { one: { two: [1, { three: 1 }, { four: 3, five: { six: 3 } }] } }, + current: { one: { two: [1, { three: 1 }, { four: 3, five: { six: 1 } }] } }, + }, + { + previous: { one: { two: [1, { three: 1 }, { four: 3, five: { sixxx: 3 } }] } }, + current: { one: { two: [1, { three: 1 }, { four: 3, five: { six: 1 } }] } }, + }, + ]; + + data.forEach((item) => { + expect(reducerUtils.observeChanges( + item.previous, item.current, + )).toMatchSnapshot(); + }); + }); + + it('observeChanges test filter', () => { + const data = [ + { + previous: { one: { two: 2, three: 3 } }, + current: { one: { two: 2, three: 4 } }, + filter: { one: ['two'] }, + }, + { + previous: { one: { two: 2, three: 3 } }, + current: { one: { two: 1, three: 3 } }, + filter: { one: ['two'] }, + }, + ]; + + data.forEach((item) => { + expect(reducerUtils.observeChanges( + item.previous, item.current, item.filter, + )).toMatchSnapshot(); + }); + }); +}); diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index 9b5ba7f5..3d6a8de4 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -34,7 +34,7 @@ export const getSelectedDevice = (state: State): ?TrezorDevice => { export const isSelectedDevice = (current: ?TrezorDevice, device: ?TrezorDevice): boolean => !!((current && device && (current.path === device.path && current.instance === device.instance))); // find device by id and state -export const findDevice = (devices: Array, deviceId: string, deviceState: string, instance: ?number): ?TrezorDevice => devices.find((d) => { +export const findDevice = (devices: Array, deviceId: string, deviceState: string /*, instance: ?number*/): ?TrezorDevice => devices.find((d) => { // TODO: && (instance && d.instance === instance) if (d.features && d.features.device_id === deviceId && d.state === deviceState) { return true; @@ -116,21 +116,54 @@ 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(prev); + 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(current); + // 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; + + // 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 9455dfbb..76db7638 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -21,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! @@ -88,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/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 398b9ef4..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'; @@ -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/index.js b/src/views/Wallet/components/LeftNavigation/index.js index 0856d41f..e399e10d 100644 --- a/src/views/Wallet/components/LeftNavigation/index.js +++ b/src/views/Wallet/components/LeftNavigation/index.js @@ -79,7 +79,7 @@ type TransitionMenuProps = { children?: React.Node; } -// TransitionMenu needs to dispatch window.resize even +// TransitionMenu needs to dispatch window.resize event // in order to StickyContainer be recalculated const TransitionMenu = (props: TransitionMenuProps): React$Element => ( @@ -108,20 +108,15 @@ type State = { 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, clicked: false, }; } - componentDidMount() { - this.setState({ - animationType: null, - shouldRenderDeviceSelection: false, - }); - } - componentWillReceiveProps(nextProps) { const { dropdownOpened, selectedDevice } = nextProps.wallet; const hasNetwork = nextProps.location.state && nextProps.location.state.network; diff --git a/src/views/Wallet/index.js b/src/views/Wallet/index.js index 0bf9849a..c78a3020 100644 --- a/src/views/Wallet/index.js +++ b/src/views/Wallet/index.js @@ -12,13 +12,15 @@ import type { State } from 'flowtype'; import Header from 'components/Header'; import Footer from 'components/Footer'; import ModalContainer from 'components/modals'; -import Notifications from 'components/Notification'; +import ContextNotifications from 'components/notifications/Context'; + import Log from 'components/Log'; import LeftNavigation from './components/LeftNavigation/Container'; import TopNavigationAccount from './components/TopNavigationAccount'; import TopNavigationDeviceSettings from './components/TopNavigationDeviceSettings'; + type WalletContainerProps = { wallet: $ElementType, children?: React.Node @@ -89,7 +91,7 @@ const Wallet = (props: WalletContainerProps) => ( - + { props.children } diff --git a/src/views/Wallet/views/AccountReceive/index.js b/src/views/Wallet/views/AccountReceive/index.js index c3864b60..202ceee1 100644 --- a/src/views/Wallet/views/AccountReceive/index.js +++ b/src/views/Wallet/views/AccountReceive/index.js @@ -10,8 +10,6 @@ import colors from 'config/colors'; import Tooltip from 'components/Tooltip'; import { QRCode } from 'react-qr-svg'; -import SelectedAccount from 'views/Wallet/components/SelectedAccount'; - import { FONT_SIZE, FONT_WEIGHT, FONT_FAMILY } from 'config/variables'; import type { Props } from './Container'; @@ -154,87 +152,85 @@ const AccountReceive = (props: Props) => { const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified; return ( - - - Receive Ethereum or tokens - - {((addressVerified || addressUnverified) && !isAddressVerifying) && ( - - {addressUnverified ? ( - - Unverified address. -
- {device.connected && device.available ? 'Show on TREZOR' : 'Connect your TREZOR to verify it.'} -
- ) : ( - - {device.connected ? 'Show on TREZOR' : 'Connect your TREZOR to verify address.'} - - )} - - )} - > - props.showAddress(account.addressPath)} - > - - -
- )} - {address} - - {isAddressVerifying && ( - - - - - Check address on your Trezor - - - )} - {/* {isAddressVerifying && ( - {account.network} account #{account.index + 1} - )} */} - {(addressVerified || addressUnverified) && ( - - )} - {!(addressVerified || addressUnverified) && ( - + Receive Ethereum or tokens + + {((addressVerified || addressUnverified) && !isAddressVerifying) && ( + + {addressUnverified ? ( + + Unverified address. +
+ {device.connected && device.available ? 'Show on TREZOR' : 'Connect your TREZOR to verify it.'} +
+ ) : ( + + {device.connected ? 'Show on TREZOR' : 'Connect your TREZOR to verify address.'} + + )} + + )} + > + props.showAddress(account.addressPath)} - isDisabled={device.connected && !discovery.completed} > - + +
+ )} + {address} + + {isAddressVerifying && ( + + + + - Show full address -
- )} -
-
-
+ Check address on your Trezor + + + )} + {/* {isAddressVerifying && ( + {account.network} account #{account.index + 1} + )} */} + {(addressVerified || addressUnverified) && ( + + )} + {!(addressVerified || addressUnverified) && ( + props.showAddress(account.addressPath)} + isDisabled={device.connected && !discovery.completed} + > + + Show full address + + )} + +
); }; -export default AccountReceive; +export default AccountReceive; \ No newline at end of file diff --git a/src/views/Wallet/views/AccountSend/index.js b/src/views/Wallet/views/AccountSend/index.js index e634c4aa..7007d92d 100644 --- a/src/views/Wallet/views/AccountSend/index.js +++ b/src/views/Wallet/views/AccountSend/index.js @@ -12,7 +12,6 @@ import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables'; import colors from 'config/colors'; import P from 'components/Paragraph'; import { H2 } from 'components/Heading'; -import SelectedAccount from 'views/Wallet/components/SelectedAccount'; import type { Token } from 'flowtype'; import AdvancedForm from './components/AdvancedForm'; import PendingTransactions from './components/PendingTransactions'; @@ -191,6 +190,7 @@ const AccountSend = (props: Props) => { network, discovery, tokens, + shouldRender, } = props.selectedAccount; const { address, @@ -219,6 +219,8 @@ const AccountSend = (props: Props) => { onSend, } = props.sendFormActions; + if (!device || !account || !discovery || !network || !shouldRender) return null; + const isCurrentCurrencyToken = networkSymbol !== currency; let selectedTokenBalance = 0; @@ -227,8 +229,6 @@ const AccountSend = (props: Props) => { selectedTokenBalance = selectedToken.balance; } - if (!device || !account || !discovery || !network) return null; - let isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending; let sendButtonText: string = 'Send'; if (networkSymbol !== currency && amount.length > 0 && !errors.amount) { @@ -252,157 +252,155 @@ const AccountSend = (props: Props) => { const isAdvancedSettingsHidden = !advanced; return ( - - - Send Ethereum or tokens - - onAddressChange(event.target.value)} - /> - - - - - Amount - {(isCurrentCurrencyToken && selectedToken) && ( - You have: {selectedTokenBalance} {selectedToken.symbol} + + Send Ethereum or tokens + + onAddressChange(event.target.value)} + /> + + + + + Amount + {(isCurrentCurrencyToken && selectedToken) && ( + You have: {selectedTokenBalance} {selectedToken.symbol} + )} + + )} + value={amount} + onChange={event => onAmountChange(event.target.value)} + bottomText={errors.amount || warnings.amount || infos.amount} + sideAddons={[ + ( + onSetMax()} + isActive={setMax} + > + {!setMax && ( + )} - - )} - value={amount} - onChange={event => onAmountChange(event.target.value)} - bottomText={errors.amount || warnings.amount || infos.amount} - sideAddons={[ - ( - onSetMax()} - isActive={setMax} - > - {!setMax && ( - - )} - {setMax && ( - - )} - Set max - - ), - ( - - ), - ]} - /> - - - - - Fee - {gasPriceNeedsUpdate && ( - - - Recommended fees updated. Click here to use them - - )} - - ( + +

{option.value}

+

{option.label}

+ + )} + /> + + + + + Advanced settings + - + - - onSend()} > - Advanced settings - - - - {isAdvancedSettingsHidden && ( - onSend()} - > - {sendButtonText} - - )} - - - {advanced && ( - - onSend()} - > - {sendButtonText} - - + {sendButtonText} + )} + - {props.selectedAccount.pending.length > 0 && ( - - )} - - + {advanced && ( + + onSend()} + > + {sendButtonText} + + + )} + + {props.selectedAccount.pending.length > 0 && ( + + )} + ); }; diff --git a/src/views/Wallet/views/AccountSummary/index.js b/src/views/Wallet/views/AccountSummary/index.js index f5e7b369..405428b9 100644 --- a/src/views/Wallet/views/AccountSummary/index.js +++ b/src/views/Wallet/views/AccountSummary/index.js @@ -11,7 +11,6 @@ import Tooltip from 'components/Tooltip'; import CoinLogo from 'components/images/CoinLogo'; import * as stateUtils from 'reducers/utils'; -import SelectedAccount from 'views/Wallet/components/SelectedAccount'; import Link from 'components/Link'; import AccountBalance from './components/AccountBalance'; import AddedToken from './components/AddedToken'; @@ -65,17 +64,18 @@ const AccountSummary = (props: Props) => { network, tokens, pending, + shouldRender, } = props.selectedAccount; // flow - if (!device || !account || !network) return ; + if (!device || !account || !network || !shouldRender) return null; const explorerLink: string = `${network.explorer.address}${account.address}`; const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol); const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10); return ( - + @@ -155,7 +155,7 @@ const AccountSummary = (props: Props) => { /> ))} - + ); }; diff --git a/src/views/Wallet/views/Acquire/index.js b/src/views/Wallet/views/Acquire/index.js index 618a7a1f..b514b9c7 100644 --- a/src/views/Wallet/views/Acquire/index.js +++ b/src/views/Wallet/views/Acquire/index.js @@ -46,7 +46,7 @@ const Acquire = (props: Props) => { export default connect( (state: State) => ({ - acquiring: state.connect.acquiring, + acquiring: state.connect.acquiringDevice, }), (dispatch: Dispatch) => ({ acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch), diff --git a/src/views/Wallet/views/UnreadableDevice/index.js b/src/views/Wallet/views/UnreadableDevice/index.js index a9f0273b..15e99283 100644 --- a/src/views/Wallet/views/UnreadableDevice/index.js +++ b/src/views/Wallet/views/UnreadableDevice/index.js @@ -1,14 +1,8 @@ /* @flow */ - import React from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; import styled from 'styled-components'; import { Notification } from 'components/Notification'; -import * as TrezorConnectActions from 'actions/TrezorConnectActions'; - -import type { State, Dispatch } from 'flowtype'; const Wrapper = styled.div``; @@ -23,11 +17,4 @@ const UnreadableDevice = () => ( ); -export default connect( - (state: State) => ({ - acquiring: state.connect.acquiring, - }), - (dispatch: Dispatch) => ({ - acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch), - }), -)(UnreadableDevice); +export default UnreadableDevice;