From 0c0f9e7ac8012922e4c50371ba9ae57ef67222d2 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Wed, 26 Sep 2018 14:11:37 +0200 Subject: [PATCH 01/13] rewritten actions --- src/actions/SelectedAccountActions.js | 267 ++++++++++++++++++------- src/actions/WalletActions.js | 40 ++-- src/reducers/SelectedAccountReducer.js | 12 +- src/reducers/utils/index.js | 1 + src/services/WalletService.js | 4 +- 5 files changed, 231 insertions(+), 93 deletions(-) diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index 4e01c1a5..a5f1a91d 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -1,12 +1,8 @@ /* @flow */ - -import { LOCATION_CHANGE } from 'react-router-redux'; import * as ACCOUNT from 'actions/constants/account'; -import * as NOTIFICATION from 'actions/constants/notification'; -import * as PENDING from 'actions/constants/pendingTx'; - -import * as stateUtils from 'reducers/utils'; +import * as reducerUtils from 'reducers/utils'; +import { initialState } from 'reducers/SelectedAccountReducer'; import type { AsyncAction, @@ -16,85 +12,216 @@ import type { 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; + title: string; + message?: string; + visible: boolean; +} + 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 getAccountStatus = (state: State, selectedAccount: SelectedAccountState): ?AccountStatus => { + const device = state.wallet.selectedDevice; + if (!device || !device.state) { + return { + type: 'info', + title: 'Loading device...', + visible: 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...', + visible: false, + }; + } + + const blockchain = state.blockchain.find(b => b.name === network.network); + if (blockchain && !blockchain.connected) { + return { + type: 'backend', + title: 'Backend is not connected', + visible: false, + }; + } + + // 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...', + visible: false, + }; + } + // 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', + visible: false, + }; + } + + // case 3: device is disconnected + return { + type: 'info', + title: `Device ${device.instanceLabel} is disconnected`, + message: 'Connect device to load accounts', + visible: false, + }; + } + + if (discovery.completed) { + // case 4: account not found and discovery is completed + return { + type: 'warning', + title: 'Account does not exist', + visible: false, + }; + } + + // case 6: discovery is not completed yet + return { + type: 'info', + title: 'Loading accounts...', + visible: 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`, + visible: true, + }; + } + if (!device.available) { + return { + type: 'info', + title: `Device ${device.instanceLabel} is unavailable`, + message: 'Change passphrase settings to use this device', + visible: true, + }; + } + + // Additional status: account does exists, but waiting for discovery to complete + if (discovery && !discovery.completed) { + return { + type: 'info', + title: 'Loading accounts...', + visible: true, + }; + } + + return null; +}; + +export const observe = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + // ignore actions dispatched from this process + if (action.type === ACCOUNT.UPDATE_SELECTED_ACCOUNT) { + return; + } + const state: State = getState(); const { location } = state.router; - - // 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, - }; - - 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; - } - } else if (payload[key] !== state.selectedAccount[key]) { - needUpdate = true; - } - }); - - if (needUpdate) { + if (!location.state.account) { + // displayed route is not an account route + if (state.selectedAccount.location.length > 1) { + // reset "selectedAccount" reducer to default dispatch({ type: ACCOUNT.UPDATE_SELECTED_ACCOUNT, - payload, + payload: initialState, }); - - 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, - }); - }, - }, - ], - }, - }); - }); - } } + return; + } + + // 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, + visible: false, + }; + + // get "selectedAccount" status from newState + const status = getAccountStatus(state, newState); + newState.notification = status || null; + newState.visible = status ? status.visible : true; + + // check if newState is different than previous state + const stateChanged = reducerUtils.observeChanges(prevState.selectedAccount, newState, ['location', 'account', 'network', 'discovery', 'tokens', 'pending', 'status', 'visible']); + if (stateChanged) { + // update values in reducer + dispatch({ + type: ACCOUNT.UPDATE_SELECTED_ACCOUNT, + payload: newState, + }); + + // TODO: move this to send account actions + /* + 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, + }); + }, + }, + ], + }, + }); + }); + } + */ } }; diff --git a/src/actions/WalletActions.js b/src/actions/WalletActions.js index 9126c541..983b86b2 100644 --- a/src/actions/WalletActions.js +++ b/src/actions/WalletActions.js @@ -1,9 +1,7 @@ /* @flow */ - -import { LOCATION_CHANGE } from 'react-router-redux'; import * as WALLET from 'actions/constants/wallet'; -import * as stateUtils from 'reducers/utils'; +import * as reducerUtils from 'reducers/utils'; import type { Device, @@ -81,25 +79,29 @@ export const clearUnavailableDevicesData = (prevState: State, device: Device): T }; -export const updateSelectedValues = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const locationChange: boolean = action.type === LOCATION_CHANGE; +export const observe = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { + // ignore actions dispatched from this process + if (action.type === WALLET.SET_SELECTED_DEVICE || action.type === WALLET.UPDATE_SELECTED_DEVICE) { + return; + } const state: State = getState(); + const locationChanged = reducerUtils.observeChanges(prevState.router.location, state.router.location, ['pathname']); + 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, + }); } } }; \ No newline at end of file diff --git a/src/reducers/SelectedAccountReducer.js b/src/reducers/SelectedAccountReducer.js index 68fb56d6..f64ea582 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, + }, + visible: boolean, }; export const initialState: State = { @@ -26,6 +32,8 @@ export const initialState: State = { tokens: [], pending: [], discovery: null, + notification: null, + visible: false, }; export default (state: State = initialState, action: Action): State => { diff --git a/src/reducers/utils/index.js b/src/reducers/utils/index.js index 9b5ba7f5..c8b8d56f 100644 --- a/src/reducers/utils/index.js +++ b/src/reducers/utils/index.js @@ -128,6 +128,7 @@ export const observeChanges = (prev: ?(Object | Array), current: ?(Object | if (prev instanceof Object && current instanceof Object) { for (let i = 0; i < fields.length; i++) { const key = fields[i]; + if (Array.isArray(prev[key]) && Array.isArray(current[key])) return prev[key].length !== current[key].length; if (prev[key] !== current[key]) return true; } } diff --git a/src/services/WalletService.js b/src/services/WalletService.js index 9455dfbb..881f293b 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -92,10 +92,10 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa api.dispatch(SendFormActionActions.observe(prevState, action)); // update common values in WallerReducer - api.dispatch(WalletActions.updateSelectedValues(prevState, action)); + api.dispatch(WalletActions.observe(prevState, action)); // update common values in SelectedAccountReducer - api.dispatch(SelectedAccountActions.updateSelectedValues(prevState, action)); + api.dispatch(SelectedAccountActions.observe(prevState, action)); return action; }; From 25083b5f19ee45c7142c61a58880ba5ba0e0c7b4 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Wed, 26 Sep 2018 14:11:54 +0200 Subject: [PATCH 02/13] new components --- src/components/notifications/App/Container.js | 0 src/components/notifications/App/index.js | 0 .../Context/components/Account/index.js | 19 +++++++ .../Context/components/Action/index.js | 20 +++++++ .../Context/components/Static/index.js | 17 ++++++ src/components/notifications/Context/index.js | 55 +++++++++++++++++++ 6 files changed, 111 insertions(+) create mode 100644 src/components/notifications/App/Container.js create mode 100644 src/components/notifications/App/index.js create mode 100644 src/components/notifications/Context/components/Account/index.js create mode 100644 src/components/notifications/Context/components/Action/index.js create mode 100644 src/components/notifications/Context/components/Static/index.js create mode 100644 src/components/notifications/Context/index.js 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..d6ef289a --- /dev/null +++ b/src/components/notifications/Context/components/Account/index.js @@ -0,0 +1,19 @@ +/* @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 (); + } + 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..73e16893 --- /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..786c6ce1 --- /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 From 2804275ca9f8b89b1854733a31c0e35bc20d5900 Mon Sep 17 00:00:00 2001 From: Szymon Lesisz Date: Wed, 26 Sep 2018 14:12:05 +0200 Subject: [PATCH 03/13] update old components --- src/views/Wallet/index.js | 6 +- .../Wallet/views/AccountReceive/index.js | 143 ++++----- src/views/Wallet/views/AccountSend/index.js | 289 +++++++++--------- .../Wallet/views/AccountSummary/index.js | 8 +- 4 files changed, 217 insertions(+), 229 deletions(-) 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..1a28338a 100644 --- a/src/views/Wallet/views/AccountReceive/index.js +++ b/src/views/Wallet/views/AccountReceive/index.js @@ -154,86 +154,75 @@ 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 + + {isAddressVerifying && ( + Confirm address on TREZOR + )} + {((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} > - - Show full address -
- )} -
-
-
+ + + )} + {address} + + {isAddressVerifying && ( + {account.network} account #{account.index + 1} + )} + {(addressVerified || addressUnverified) && ( + + )} + {!(addressVerified || addressUnverified) && ( + props.showAddress(account.addressPath)} + isDisabled={device.connected && !discovery.completed} + > + + Show full address + + )} + + ); }; diff --git a/src/views/Wallet/views/AccountSend/index.js b/src/views/Wallet/views/AccountSend/index.js index e634c4aa..6da252b5 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'; @@ -252,157 +251,155 @@ const AccountSend = (props: Props) => { const isAdvancedSettingsHidden = !advanced; return ( - - - Send Ethereum or tokens - - onAddressChange(event.target.value)} - /> - + + 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 && ( - - )} - {setMax && ( - - )} - Set max - - ), - ( - - ), - ]} - /> - - - - - Fee - {gasPriceNeedsUpdate && ( - - - Recommended fees updated. Click here to use them - - )} - - + 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 && ( + + )} + {setMax && ( + + )} + Set max + + ), + ( + + ), + ]} + /> + - {advanced && ( - - onSend()} - > - {sendButtonText} - - - )} + + + Fee + {gasPriceNeedsUpdate && ( + + + Recommended fees updated. Click here to use them + + )} + +