1
0
mirror of https://github.com/trezor/trezor-wallet synced 2025-07-04 05:42:34 +00:00
This commit is contained in:
Vladimir Volek 2018-10-03 15:53:23 +02:00
commit ad553e436a
39 changed files with 1191 additions and 571 deletions

View File

@ -1,4 +1,4 @@
# TREZOR Wallet # Trezor Ethereum wallet
To install dependencies run `npm install` or `yarn` To install dependencies run `npm install` or `yarn`

View File

@ -1,7 +1,7 @@
module.exports = { module.exports = {
rootDir: './src', rootDir: './src',
automock: false, automock: false,
coverageDirectory: 'coverage/', coverageDirectory: '../coverage/',
collectCoverage: true, collectCoverage: true,
testURL: 'http://localhost', testURL: 'http://localhost',
modulePathIgnorePatterns: [ modulePathIgnorePatterns: [
@ -12,6 +12,7 @@ module.exports = {
], ],
collectCoverageFrom: [ collectCoverageFrom: [
'utils/**.js', 'utils/**.js',
'reducers/utils/**.js',
], ],
setupFiles: [ setupFiles: [
'./support/setupJest.js', './support/setupJest.js',

View File

@ -2,8 +2,13 @@
import * as PENDING from 'actions/constants/pendingTx'; import * as PENDING from 'actions/constants/pendingTx';
import type {
Action, ThunkAction, GetState, Dispatch,
} from 'flowtype';
import type { State, PendingTx } from 'reducers/PendingTxReducer'; import type { State, PendingTx } from 'reducers/PendingTxReducer';
export type PendingTxAction = { export type PendingTxAction = {
type: typeof PENDING.FROM_STORAGE, type: typeof PENDING.FROM_STORAGE,
payload: State payload: State
@ -15,9 +20,34 @@ export type PendingTxAction = {
tx: PendingTx, tx: PendingTx,
receipt?: Object, receipt?: Object,
} | { } | {
type: typeof PENDING.TX_NOT_FOUND, type: typeof PENDING.TX_REJECTED,
tx: PendingTx, tx: PendingTx,
} | { } | {
type: typeof PENDING.TX_TOKEN_ERROR, type: typeof PENDING.TX_TOKEN_ERROR,
tx: PendingTx, tx: PendingTx,
} }
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,
});
},
},
],
},
});
*/
};

View File

@ -152,7 +152,7 @@ export const getValidUrl = (action: RouterAction): PayloadAction<string> => (dis
// Disallow displaying landing page // Disallow displaying landing page
// redirect to previous url // redirect to previous url
if (!shouldBeLandingPage && landingPageUrl) { if (!shouldBeLandingPage && landingPageUrl) {
return location.pathname; return dispatch(getFirstAvailableDeviceUrl()) || location.pathname;
} }
// Regular url change during application live cycle // Regular url change during application live cycle
@ -170,22 +170,10 @@ export const getValidUrl = (action: RouterAction): PayloadAction<string> => (dis
return composedUrl || location.pathname; return composedUrl || location.pathname;
}; };
/* /*
* Utility used in "selectDevice" and "selectFirstAvailableDevice" * Compose url from requested device object and returns url
* sorting device array by "ts" (timestamp) field
*/ */
const sortDevices = (devices: Array<TrezorDevice>): Array<TrezorDevice> => devices.sort((a, b) => { const getDeviceUrl = (device: TrezorDevice | Device): PayloadAction<?string> => (dispatch: Dispatch, getState: GetState): ?string => {
if (!a.ts || !b.ts) {
return -1;
}
return a.ts > b.ts ? -1 : 1;
});
/*
* Compose url from given device object and redirect
*/
export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => {
let url: ?string; let url: ?string;
if (!device.features) { if (!device.features) {
url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`; url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`;
@ -208,12 +196,7 @@ export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dis
} }
} }
} }
return url;
const currentParams: RouterLocationState = getState().router.location.state;
const requestedParams = dispatch(pathToParams(url));
if (currentParams.device !== requestedParams.device || currentParams.deviceInstance !== requestedParams.deviceInstance) {
dispatch(goto(url));
}
}; };
/* /*
@ -223,17 +206,54 @@ export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dis
* 3. Saved with latest timestamp * 3. Saved with latest timestamp
* OR redirect to landing page * OR redirect to landing page
*/ */
export const selectFirstAvailableDevice = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { export const getFirstAvailableDeviceUrl = (): PayloadAction<?string> => (dispatch: Dispatch, getState: GetState): ?string => {
const { devices } = getState(); const { devices } = getState();
let url: ?string;
if (devices.length > 0) { if (devices.length > 0) {
const unacquired = devices.find(d => !d.features); const unacquired = devices.find(d => !d.features);
if (unacquired) { if (unacquired) {
dispatch(selectDevice(unacquired)); url = dispatch(getDeviceUrl(unacquired));
} else { } else {
const latest: Array<TrezorDevice> = sortDevices(devices); const latest: Array<TrezorDevice> = sortDevices(devices);
const firstConnected: ?TrezorDevice = latest.find(d => d.connected); 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<TrezorDevice>): Array<TrezorDevice> => 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 { } else {
dispatch(gotoLandingPage()); dispatch(gotoLandingPage());
} }

View File

@ -1,100 +1,210 @@
/* @flow */ /* @flow */
import { LOCATION_CHANGE } from 'react-router-redux'; import { LOCATION_CHANGE } from 'react-router-redux';
import * as WALLET from 'actions/constants/wallet';
import * as ACCOUNT from 'actions/constants/account'; 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 PENDING from 'actions/constants/pendingTx';
import * as stateUtils from 'reducers/utils'; import * as reducerUtils from 'reducers/utils';
import type { import type {
AsyncAction, PayloadAction,
Action, Action,
GetState, GetState,
Dispatch, Dispatch,
State, State,
} from 'flowtype'; } from 'flowtype';
type SelectedAccountState = $ElementType<State, 'selectedAccount'>;
export type SelectedAccountAction = { export type SelectedAccountAction = {
type: typeof ACCOUNT.DISPOSE, type: typeof ACCOUNT.DISPOSE,
} | { } | {
type: typeof ACCOUNT.UPDATE_SELECTED_ACCOUNT, type: typeof ACCOUNT.UPDATE_SELECTED_ACCOUNT,
payload: $ElementType<State, 'selectedAccount'> 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 => ({ export const dispose = (): Action => ({
type: ACCOUNT.DISPOSE, type: ACCOUNT.DISPOSE,
}); });
export const updateSelectedValues = (prevState: State, action: Action): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => { const getAccountStatus = (state: State, selectedAccount: SelectedAccountState): ?AccountStatus => {
const locationChange: boolean = action.type === LOCATION_CHANGE; 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,
};
}
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,
};
}
// 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,
};
}
// 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,
};
}
// 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<boolean> => (dispatch: Dispatch, getState: GetState): boolean => {
// ignore not listed actions
if (actions.indexOf(action.type) < 0) return false;
const state: State = getState(); const state: State = getState();
const { location } = state.router; const { location } = state.router;
// displayed route is not an account route
if (!location.state.account) return false;
// handle devices state change (from trezor-connect events or location change) // get new values for selected account
if (locationChange const account = reducerUtils.getSelectedAccount(state);
|| prevState.accounts !== state.accounts const network = reducerUtils.getSelectedNetwork(state);
|| prevState.discovery !== state.discovery const discovery = reducerUtils.getDiscoveryProcess(state);
|| prevState.tokens !== state.tokens const tokens = reducerUtils.getAccountTokens(state, account);
|| prevState.pending !== state.pending) { const pending = reducerUtils.getAccountPendingTx(state.pending, account);
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<State, 'selectedAccount'> = { // prepare new state for "selectedAccount" reducer
location: location.pathname, const newState: SelectedAccountState = {
account, location: state.router.location.pathname,
network, account,
discovery, network,
tokens, discovery,
pending, tokens,
}; pending,
notification: null,
shouldRender: false,
};
let needUpdate: boolean = false; // get "selectedAccount" status from newState
Object.keys(payload).forEach((key) => { const status = getAccountStatus(state, newState);
if (Array.isArray(payload[key])) { newState.notification = status || null;
if (Array.isArray(state.selectedAccount[key]) && payload[key].length !== state.selectedAccount[key].length) { newState.shouldRender = status ? status.shouldRender : true;
needUpdate = true; // check if newState is different than previous state
} const stateChanged = reducerUtils.observeChanges(prevState.selectedAccount, newState, {
} else if (payload[key] !== state.selectedAccount[key]) { account: ['balance', 'nonce'],
needUpdate = true; discovery: ['accountIndex', 'interrupted', 'completed', 'waitingForBlockchain', 'waitingForDevice'],
} });
if (stateChanged) {
// update values in reducer
dispatch({
type: ACCOUNT.UPDATE_SELECTED_ACCOUNT,
payload: newState,
}); });
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,
});
},
},
],
},
});
});
}
}
} }
return stateChanged;
}; };

View File

@ -2,6 +2,7 @@
import TrezorConnect from 'trezor-connect'; import TrezorConnect from 'trezor-connect';
import BigNumber from 'bignumber.js'; import BigNumber from 'bignumber.js';
import * as ACCOUNT from 'actions/constants/account';
import * as NOTIFICATION from 'actions/constants/notification'; import * as NOTIFICATION from 'actions/constants/notification';
import * as SEND from 'actions/constants/send'; import * as SEND from 'actions/constants/send';
import * as WEB3 from 'actions/constants/web3'; 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, type: typeof SEND.TOGGLE_ADVANCED | typeof SEND.TX_SENDING | typeof SEND.TX_ERROR,
} | SendTxAction; } | 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 => { 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(); const currentState = getState();
// do not proceed if it's not "send" url // do not proceed if it's not "send" url
if (!currentState.router.location.state.send) return; if (!currentState.router.location.state.send) return;
@ -75,16 +87,9 @@ export const observe = (prevState: ReducersState, action: Action): ThunkAction =
let shouldUpdate: boolean = false; let shouldUpdate: boolean = false;
// check if "selectedAccount" reducer changed // check if "selectedAccount" reducer changed
const selectedAccountChanged = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, ['account', 'tokens', 'pending']); shouldUpdate = reducerUtils.observeChanges(prevState.selectedAccount, currentState.selectedAccount, {
if (selectedAccountChanged) { account: ['balance', 'nonce'],
// 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;
}
// check if "sendForm" reducer changed // check if "sendForm" reducer changed
if (!shouldUpdate) { if (!shouldUpdate) {

View File

@ -71,9 +71,7 @@ export type TrezorConnectAction = {
type: typeof CONNECT.DEVICE_FROM_STORAGE, type: typeof CONNECT.DEVICE_FROM_STORAGE,
payload: Array<TrezorDevice> payload: Array<TrezorDevice>
} | { } | {
type: typeof CONNECT.START_ACQUIRING, type: typeof CONNECT.START_ACQUIRING | typeof CONNECT.STOP_ACQUIRING,
} | {
type: typeof CONNECT.STOP_ACQUIRING,
}; };
export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => { export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
@ -128,10 +126,10 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS
pendingTransportEvent: (getState().devices.length < 1), pendingTransportEvent: (getState().devices.length < 1),
}); });
} catch (error) { } catch (error) {
// dispatch({ dispatch({
// type: CONNECT.INITIALIZATION_ERROR, type: CONNECT.INITIALIZATION_ERROR,
// 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<void> => { export const deviceDisconnect = (device: Device): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise<void> => {
const selected: ?TrezorDevice = getState().wallet.selectedDevice; 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) { if (selected && selected.features && selected.features.device_id === device.features.device_id) {
dispatch(DiscoveryActions.stop(selected)); dispatch(DiscoveryActions.stop(selected));
} }
@ -218,7 +216,11 @@ export const deviceDisconnect = (device: Device): AsyncAction => async (dispatch
device: instances[0], device: instances[0],
instances, instances,
}); });
} else {
dispatch(RouterActions.selectFirstAvailableDevice());
} }
} else {
dispatch(RouterActions.selectFirstAvailableDevice());
} }
}; };

View File

@ -1,16 +1,16 @@
/* @flow */ /* @flow */
import { LOCATION_CHANGE } from 'react-router-redux'; import { LOCATION_CHANGE } from 'react-router-redux';
import { DEVICE } from 'trezor-connect';
import * as WALLET from 'actions/constants/wallet'; import * as WALLET from 'actions/constants/wallet';
import * as stateUtils from 'reducers/utils'; import * as reducerUtils from 'reducers/utils';
import type { import type {
Device, Device,
TrezorDevice, TrezorDevice,
RouterLocationState, RouterLocationState,
ThunkAction, ThunkAction,
AsyncAction, PayloadAction,
Action, Action,
Dispatch, Dispatch,
GetState, 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<boolean> => (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<void> => {
const locationChange: boolean = action.type === LOCATION_CHANGE;
const state: State = getState(); 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) // handle devices state change (from trezor-connect events or location change)
if (locationChange || prevState.devices !== state.devices) { if (locationChanged || selectedDeviceChanged) {
const device = stateUtils.getSelectedDevice(state); if (device && reducerUtils.isSelectedDevice(state.wallet.selectedDevice, device)) {
if (state.wallet.selectedDevice !== device) { dispatch({
if (device && stateUtils.isSelectedDevice(state.wallet.selectedDevice, device)) { type: WALLET.UPDATE_SELECTED_DEVICE,
dispatch({ device,
type: WALLET.UPDATE_SELECTED_DEVICE, });
device, } else {
}); dispatch({
} else { type: WALLET.SET_SELECTED_DEVICE,
dispatch({ device,
type: WALLET.SET_SELECTED_DEVICE, });
device,
});
}
} }
return true;
} }
return false;
}; };

View File

@ -139,7 +139,7 @@ export const resolvePendingTransactions = (network: string): PromiseAction<void>
const status = await instance.web3.eth.getTransaction(tx.id); const status = await instance.web3.eth.getTransaction(tx.id);
if (!status) { if (!status) {
dispatch({ dispatch({
type: PENDING.TX_NOT_FOUND, type: PENDING.TX_REJECTED,
tx, tx,
}); });
} else { } else {

View File

@ -4,5 +4,5 @@
export const FROM_STORAGE: 'pending__from_storage' = 'pending__from_storage'; export const FROM_STORAGE: 'pending__from_storage' = 'pending__from_storage';
export const ADD: 'pending__add' = 'pending__add'; export const ADD: 'pending__add' = 'pending__add';
export const TX_RESOLVED: 'pending__tx_resolved' = 'pending__tx_resolved'; 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'; export const TX_TOKEN_ERROR: 'pending__tx_token_error' = 'pending__tx_token_error';

View File

@ -1,7 +1,7 @@
/* @flow */ /* @flow */
import React from 'react'; import React from 'react';
import styled from 'styled-components'; import styled from 'styled-components';
import { NavLink } from 'react-router-dom';
import colors from 'config/colors'; import colors from 'config/colors';
const Wrapper = styled.header` const Wrapper = styled.header`
@ -13,12 +13,9 @@ const Wrapper = styled.header`
fill: ${colors.WHITE}; fill: ${colors.WHITE};
height: 28px; height: 28px;
width: 100px; width: 100px;
flex: 1;
} }
`; `;
const LinkWrapper = styled.div``;
const LayoutWrapper = styled.div` const LayoutWrapper = styled.div`
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -26,12 +23,21 @@ const LayoutWrapper = styled.div`
margin: 0 auto; margin: 0 auto;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
@media screen and (max-width: 1170px) { @media screen and (max-width: 1170px) {
padding: 0 25px; padding: 0 25px;
} }
`; `;
const Left = styled.div`
flex: 1;
display: flex;
justify-content: flex-start;
`;
const Right = styled.div``;
const A = styled.a` const A = styled.a`
color: ${colors.WHITE}; color: ${colors.WHITE};
margin-left: 24px; margin-left: 24px;
@ -54,21 +60,25 @@ const A = styled.a`
const Header = (): React$Element<string> => ( const Header = (): React$Element<string> => (
<Wrapper> <Wrapper>
<LayoutWrapper> <LayoutWrapper>
<svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 163.7 41.9" width="100%" height="100%" preserveAspectRatio="xMinYMin meet"> <Left>
<polygon points="101.1,12.8 118.2,12.8 118.2,17.3 108.9,29.9 118.2,29.9 118.2,35.2 101.1,35.2 101.1,30.7 110.4,18.1 101.1,18.1" /> <NavLink to="/">
<path d="M158.8,26.9c2.1-0.8,4.3-2.9,4.3-6.6c0-4.5-3.1-7.4-7.7-7.4h-10.5v22.3h5.8v-7.5h2.2l4.1,7.5h6.7L158.8,26.9z M154.7,22.5 h-4V18h4c1.5,0,2.5,0.9,2.5,2.2C157.2,21.6,156.2,22.5,154.7,22.5z" /> <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" viewBox="0 0 163.7 41.9" width="100%" height="100%" preserveAspectRatio="xMinYMin meet">
<path d="M130.8,12.5c-6.8,0-11.6,4.9-11.6,11.5s4.9,11.5,11.6,11.5s11.7-4.9,11.7-11.5S137.6,12.5,130.8,12.5z M130.8,30.3 c-3.4,0-5.7-2.6-5.7-6.3c0-3.8,2.3-6.3,5.7-6.3c3.4,0,5.8,2.6,5.8,6.3C136.6,27.7,134.2,30.3,130.8,30.3z" /> <polygon points="101.1,12.8 118.2,12.8 118.2,17.3 108.9,29.9 118.2,29.9 118.2,35.2 101.1,35.2 101.1,30.7 110.4,18.1 101.1,18.1" />
<polygon points="82.1,12.8 98.3,12.8 98.3,18 87.9,18 87.9,21.3 98,21.3 98,26.4 87.9,26.4 87.9,30 98.3,30 98.3,35.2 82.1,35.2 " /> <path d="M158.8,26.9c2.1-0.8,4.3-2.9,4.3-6.6c0-4.5-3.1-7.4-7.7-7.4h-10.5v22.3h5.8v-7.5h2.2l4.1,7.5h6.7L158.8,26.9z M154.7,22.5 h-4V18h4c1.5,0,2.5,0.9,2.5,2.2C157.2,21.6,156.2,22.5,154.7,22.5z" />
<path d="M24.6,9.7C24.6,4.4,20,0,14.4,0S4.2,4.4,4.2,9.7v3.1H0v22.3h0l14.4,6.7l14.4-6.7h0V12.9h-4.2V9.7z M9.4,9.7 c0-2.5,2.2-4.5,5-4.5s5,2,5,4.5v3.1H9.4V9.7z M23,31.5l-8.6,4l-8.6-4V18.1H23V31.5z" /> <path d="M130.8,12.5c-6.8,0-11.6,4.9-11.6,11.5s4.9,11.5,11.6,11.5s11.7-4.9,11.7-11.5S137.6,12.5,130.8,12.5z M130.8,30.3 c-3.4,0-5.7-2.6-5.7-6.3c0-3.8,2.3-6.3,5.7-6.3c3.4,0,5.8,2.6,5.8,6.3C136.6,27.7,134.2,30.3,130.8,30.3z" />
<path d="M79.4,20.3c0-4.5-3.1-7.4-7.7-7.4H61.2v22.3H67v-7.5h2.2l4.1,7.5H80l-4.9-8.3C77.2,26.1,79.4,24,79.4,20.3z M71,22.5h-4V18 h4c1.5,0,2.5,0.9,2.5,2.2C73.5,21.6,72.5,22.5,71,22.5z" /> <polygon points="82.1,12.8 98.3,12.8 98.3,18 87.9,18 87.9,21.3 98,21.3 98,26.4 87.9,26.4 87.9,30 98.3,30 98.3,35.2 82.1,35.2 " />
<polygon points="40.5,12.8 58.6,12.8 58.6,18.1 52.4,18.1 52.4,35.2 46.6,35.2 46.6,18.1 40.5,18.1 " /> <path d="M24.6,9.7C24.6,4.4,20,0,14.4,0S4.2,4.4,4.2,9.7v3.1H0v22.3h0l14.4,6.7l14.4-6.7h0V12.9h-4.2V9.7z M9.4,9.7 c0-2.5,2.2-4.5,5-4.5s5,2,5,4.5v3.1H9.4V9.7z M23,31.5l-8.6,4l-8.6-4V18.1H23V31.5z" />
</svg> <path d="M79.4,20.3c0-4.5-3.1-7.4-7.7-7.4H61.2v22.3H67v-7.5h2.2l4.1,7.5H80l-4.9-8.3C77.2,26.1,79.4,24,79.4,20.3z M71,22.5h-4V18 h4c1.5,0,2.5,0.9,2.5,2.2C73.5,21.6,72.5,22.5,71,22.5z" />
<LinkWrapper> <polygon points="40.5,12.8 58.6,12.8 58.6,18.1 52.4,18.1 52.4,35.2 46.6,35.2 46.6,18.1 40.5,18.1 " />
</svg>
</NavLink>
</Left>
<Right>
<A href="https://trezor.io/" target="_blank" rel="noreferrer noopener">TREZOR</A> <A href="https://trezor.io/" target="_blank" rel="noreferrer noopener">TREZOR</A>
<A href="https://doc.satoshilabs.com/trezor-user/" target="_blank" rel="noreferrer noopener">Docs</A> <A href="https://doc.satoshilabs.com/trezor-user/" target="_blank" rel="noreferrer noopener">Docs</A>
<A href="https://blog.trezor.io/" target="_blank" rel="noreferrer noopener">Blog</A> <A href="https://blog.trezor.io/" target="_blank" rel="noreferrer noopener">Blog</A>
<A href="https://trezor.io/support/" target="_blank" rel="noreferrer noopener">Support</A> <A href="https://trezor.io/support/" target="_blank" rel="noreferrer noopener">Support</A>
</LinkWrapper> </Right>
</LayoutWrapper> </LayoutWrapper>
</Wrapper> </Wrapper>
); );

View File

@ -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 (
<Wrapper>
{Object.keys(sortedNotifications).map(group => (
<Group
groupNotifications={notificationGroups[group]}
type={group}
/>
))}
</Wrapper>
);
}
}
export default NotificationsGroup;

View File

@ -1,6 +1,7 @@
/* @flow */ /* @flow */
import React from 'react'; import React from 'react';
import media from 'styled-media-query';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import styled, { css } from 'styled-components'; import styled, { css } from 'styled-components';
@ -37,6 +38,7 @@ const Wrapper = styled.div`
background: ${colors.TEXT_SECONDARY}; background: ${colors.TEXT_SECONDARY};
padding: 24px 48px 24px 24px; padding: 24px 48px 24px 24px;
display: flex; display: flex;
min-height: 90px;
flex-direction: row; flex-direction: row;
text-align: left; text-align: left;
@ -59,11 +61,19 @@ const Wrapper = styled.div`
color: ${colors.ERROR_PRIMARY}; color: ${colors.ERROR_PRIMARY};
background: ${colors.ERROR_SECONDARY}; background: ${colors.ERROR_SECONDARY};
`} `}
${media.lessThan('610px')`
flex-direction: column;
`}
`; `;
const Body = styled.div` const Body = styled.div`
display: flex; display: flex;
margin-right: 40px; width: 60%;
${media.lessThan('610px')`
width: 100%;
`}
`; `;
const Title = styled.div` const Title = styled.div`
@ -85,28 +95,36 @@ const Message = styled.div`
const StyledIcon = styled(Icon)` const StyledIcon = styled(Icon)`
position: relative; position: relative;
top: -7px; top: -7px;
min-width: 20px;
`; `;
const MessageContent = styled.div` const IconWrapper = styled.div`
height: 20px; min-width: 20px;
display: flex;
`; `;
const Texts = styled.div` const Texts = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex: 1;
`; `;
const AdditionalContent = styled.div` const AdditionalContent = styled.div`
flex: 1;
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
align-items: flex-end; align-items: flex-end;
flex: 1;
`; `;
const ActionContent = styled.div` const ActionContent = styled.div`
display: flex;
justify-content: right; justify-content: right;
align-items: flex-end; 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<string> => { export const Notification = (props: NProps): React$Element<string> => {
@ -126,20 +144,20 @@ export const Notification = (props: NProps): React$Element<string> => {
</CloseClick> </CloseClick>
)} )}
<Body> <Body>
<MessageContent> <IconWrapper>
<StyledIcon <StyledIcon
color={getColor(props.type)} color={getColor(props.type)}
icon={getIcon(props.type)} icon={getIcon(props.type)}
/> />
<Texts> </IconWrapper>
<Title>{ props.title }</Title> <Texts>
{ props.message && ( <Title>{ props.title }</Title>
<Message> { props.message && (
<p dangerouslySetInnerHTML={{ __html: props.message }} /> <Message>
</Message> <p dangerouslySetInnerHTML={{ __html: props.message }} />
) } </Message>
</Texts> ) }
</MessageContent> </Texts>
</Body> </Body>
<AdditionalContent> <AdditionalContent>
{props.actions && props.actions.length > 0 && ( {props.actions && props.actions.length > 0 && (

View File

@ -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 (
<Notification
type="error"
title={notification.title}
message={notification.message}
actions={
[{
label: 'Connect',
callback: async () => {
await props.blockchainReconnect('trop');
},
}]
}
/>
);
// return (<Notification type="error" title={notification.title} message={notification.message} />);
}
return (<Notification type={notification.type} title={notification.title} message={notification.message} />);
}
return null;
};

View File

@ -45,18 +45,18 @@ class Group extends Component {
if (this.state.visible) { if (this.state.visible) {
this.setState({ this.setState({
visible: false, visible: false,
visibleCount: 0, visibleCount: this.props.groupNotifications.length,
}); });
} else { } else {
this.setState({ this.setState({
visible: true, visible: true,
visibleCount: this.props.groupNotifications.length, visibleCount: 0,
}); });
} }
} }
render() { render() {
const { type, groupNotifications } = this.props; const { type, groupNotifications, close } = this.props;
const color = getColor(type); const color = getColor(type);
return ( return (
<Wrapper> <Wrapper>
@ -77,7 +77,7 @@ class Group extends Component {
icon={ICONS.ARROW_DOWN} icon={ICONS.ARROW_DOWN}
color={colors.TEXT_SECONDARY} color={colors.TEXT_SECONDARY}
size={24} size={24}
isActive={this.state.visible} isActive={!this.state.visible}
canAnimate canAnimate
/> />
</Right> </Right>
@ -88,10 +88,13 @@ class Group extends Component {
.slice(0, this.state.visibleCount) .slice(0, this.state.visibleCount)
.map(notification => ( .map(notification => (
<Notification <Notification
key={notification.title} key={notification.key}
type={notification.type} type={notification.type}
title={notification.title} title={notification.title}
message={notification.message} message={notification.message}
cancelable={notification.cancelable}
actions={notification.actions}
close={close}
/> />
))} ))}
</Body> </Body>
@ -102,12 +105,15 @@ class Group extends Component {
Group.propTypes = { Group.propTypes = {
type: PropTypes.string, type: PropTypes.string,
groupNotifications: PropTypes.arrayOf({ close: PropTypes.func.isRequired,
key: PropTypes.string, groupNotifications: PropTypes.arrayOf(
type: PropTypes.string, PropTypes.shape({
title: PropTypes.string, key: PropTypes.number,
message: PropTypes.string, type: PropTypes.string,
}), title: PropTypes.string,
message: PropTypes.string,
}),
),
}; };
export default Group; export default Group;

View File

@ -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 (
<Wrapper>
{Object.keys(sortedNotifications).map(group => (
<Group
key={group}
groupNotifications={notificationGroups[group]}
type={group}
close={close}
/>
))}
</Wrapper>
);
}
}
NotificationsGroup.propTypes = {
notifications: PropTypes.array.isRequired,
close: PropTypes.func.isRequired,
};
export default NotificationsGroup;

View File

@ -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 (
<NotificationsGroups
notifications={notifications}
close={close}
/>
);
};

View File

@ -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<Notification> = [];
// if (location.state.device) {
// notifications.push(<Notification key="example" type="info" title="Static example" />);
// }
return notifications;
};

View File

@ -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<State, 'router'>;
notifications: $ElementType<State, 'notifications'>;
selectedAccount: $ElementType<State, 'selectedAccount'>;
wallet: $ElementType<State, 'wallet'>;
blockchain: $ElementType<State, 'blockchain'>;
children?: React.Node;
}
export type DispatchProps = {
close: typeof NotificationActions.close;
blockchainReconnect: typeof reconnect;
}
export type Props = StateProps & DispatchProps;
type OwnProps = {};
const Notifications = (props: Props) => (
<React.Fragment>
<StaticNotifications {...props} />
<AccountNotifications {...props} />
<ActionNotifications {...props} />
</React.Fragment>
);
const mapStateToProps: MapStateToProps<State, OwnProps, StateProps> = (state: State): StateProps => ({
router: state.router,
notifications: state.notifications,
selectedAccount: state.selectedAccount,
wallet: state.wallet,
blockchain: state.blockchain,
});
const mapDispatchToProps: MapDispatchToProps<Dispatch, OwnProps, DispatchProps> = (dispatch: Dispatch): DispatchProps => ({
close: bindActionCreators(NotificationActions.close, dispatch),
blockchainReconnect: bindActionCreators(reconnect, dispatch),
});
export default connect(mapStateToProps, mapDispatchToProps)(Notifications);

View File

@ -94,7 +94,6 @@ const addDevice = (state: State, device: Device): State => {
const newDevice: TrezorDevice = device.type === 'acquired' ? { const newDevice: TrezorDevice = device.type === 'acquired' ? {
...device, ...device,
// acquiring: false,
...extended, ...extended,
} : { } : {
...device, ...device,
@ -132,8 +131,8 @@ const addDevice = (state: State, device: Device): State => {
const changedDevices: Array<TrezorDevice> = affectedDevices.filter(d => d.features && device.features const changedDevices: Array<TrezorDevice> = affectedDevices.filter(d => d.features && device.features
&& d.features.passphrase_protection === device.features.passphrase_protection).map((d) => { && d.features.passphrase_protection === device.features.passphrase_protection).map((d) => {
const extended: Object = { connected: true, available: true }; const extended2: Object = { connected: true, available: true };
return mergeDevices(d, { ...device, ...extended }); return mergeDevices(d, { ...device, ...extended2 });
}); });
if (changedDevices.length !== affectedDevices.length) { if (changedDevices.length !== affectedDevices.length) {
changedDevices.push(newDevice); changedDevices.push(newDevice);
@ -154,7 +153,6 @@ const duplicate = (state: State, device: TrezorDevice): State => {
const newDevice: TrezorDevice = { const newDevice: TrezorDevice = {
...device, ...device,
// acquiring: false,
remember: false, remember: false,
state: null, state: null,
// instance, (instance is already part of device - added in modal) // 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 // Transform JSON form local storage into State
const devicesFromStorage = (devices: Array<TrezorDevice>): State => devices.map((device: TrezorDevice) => { const devicesFromStorage = ($devices: Array<TrezorDevice>): State => $devices.map((device: TrezorDevice) => {
const extended = { const extended = {
connected: false, connected: false,
available: false, available: false,
@ -233,14 +231,14 @@ const disconnectDevice = (state: State, device: Device): State => {
const otherDevices: State = state.filter(d => affectedDevices.indexOf(d) === -1); const otherDevices: State = state.filter(d => affectedDevices.indexOf(d) === -1);
if (affectedDevices.length > 0) { if (affectedDevices.length > 0) {
const acquiredDevices = affectedDevices.filter(d => d.features && d.state).map((d) => { const acquiredDevices = affectedDevices.filter(d => d.features && d.state).map((d) => { // eslint-disable-line arrow-body-style
if (d.type === 'acquired') { return d.type === 'acquired' ? {
d.connected = false; ...d,
d.available = false; connected: false,
d.status = 'available'; available: false,
d.path = ''; status: 'available',
} path: '',
return d; } : d;
}); });
return otherDevices.concat(acquiredDevices); return otherDevices.concat(acquiredDevices);
} }
@ -249,12 +247,18 @@ const disconnectDevice = (state: State, device: Device): State => {
}; };
const onSelectedDevice = (state: State, device: ?TrezorDevice): State => { const onSelectedDevice = (state: State, device: ?TrezorDevice): State => {
if (device) { if (!device) return state;
const otherDevices: Array<TrezorDevice> = state.filter(d => d !== device);
device.ts = new Date().getTime(); const otherDevices: Array<TrezorDevice> = state.filter(d => d !== device);
return otherDevices.concat([device]); const extended = device.type === 'acquired' ? {
} ...device,
return state; 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 { export default function devices(state: State = initialState, action: Action): State {

View File

@ -65,7 +65,7 @@ export default function pending(state: State = initialState, action: Action): St
// return add(state, action.payload); // return add(state, action.payload);
case PENDING.TX_RESOLVED: case PENDING.TX_RESOLVED:
return remove(state, action.tx.id); return remove(state, action.tx.id);
case PENDING.TX_NOT_FOUND: case PENDING.TX_REJECTED:
return reject(state, action.tx.id); return reject(state, action.tx.id);
case PENDING.FROM_STORAGE: case PENDING.FROM_STORAGE:

View File

@ -11,12 +11,18 @@ import type {
} from 'flowtype'; } from 'flowtype';
export type State = { export type State = {
location?: string; location: string;
account: ?Account; account: ?Account;
network: ?Coin; network: ?Coin;
tokens: Array<Token>, tokens: Array<Token>,
pending: Array<PendingTx>, pending: Array<PendingTx>,
discovery: ?Discovery discovery: ?Discovery,
notification: ?{
type: string,
title: string,
message?: string,
},
shouldRender: boolean,
}; };
export const initialState: State = { export const initialState: State = {
@ -26,6 +32,8 @@ export const initialState: State = {
tokens: [], tokens: [],
pending: [], pending: [],
discovery: null, discovery: null,
notification: null,
shouldRender: false,
}; };
export default (state: State = initialState, action: Action): State => { export default (state: State = initialState, action: Action): State => {

View File

@ -10,9 +10,7 @@ export type SelectedDevice = {
} }
export type State = { export type State = {
// devices: Array<TrezorDevice>; initialized: boolean;
// selectedDevice: ?SelectedDevice;
discoveryComplete: boolean;
error: ?string; error: ?string;
transport: ?{ transport: ?{
type: string; type: string;
@ -26,54 +24,42 @@ export type State = {
// mobile: boolean; // mobile: boolean;
// } | {}; // } | {};
browserState: any; browserState: any;
acquiring: boolean; acquiringDevice: boolean;
} }
const initialState: State = { const initialState: State = {
// devices: [], initialized: false,
//selectedDevice: null,
discoveryComplete: false,
error: null, error: null,
transport: null, transport: null,
browserState: {}, browserState: {},
acquiring: false, acquiringDevice: false,
}; };
export default function connect(state: State = initialState, action: Action): State { export default function connect(state: State = initialState, action: Action): State {
switch (action.type) { switch (action.type) {
case UI.IFRAME_HANDSHAKE: // trezor-connect iframe didn't loaded properly
return {
...state,
browserState: action.payload.browser,
};
case CONNECT.START_ACQUIRING:
return {
...state,
acquiring: true,
};
case CONNECT.STOP_ACQUIRING:
return {
...state,
acquiring: false,
};
case CONNECT.INITIALIZATION_ERROR: case CONNECT.INITIALIZATION_ERROR:
return { return {
...state, ...state,
error: action.error, error: action.error,
}; };
// trezor-connect iframe loaded
case UI.IFRAME_HANDSHAKE:
return {
...state,
initialized: true,
browserState: action.payload.browser,
};
// trezor-connect (trezor-link) initialized
case TRANSPORT.START: case TRANSPORT.START:
return { return {
...state, ...state,
transport: action.payload, transport: action.payload,
error: null, error: null,
}; };
// trezor-connect (trezor-link)
// will be called continuously in interval until connection (bridge/webusb) will be established
case TRANSPORT.ERROR: case TRANSPORT.ERROR:
return { return {
...state, ...state,
@ -82,6 +68,17 @@ export default function connect(state: State = initialState, action: Action): St
transport: null, transport: null,
}; };
case CONNECT.START_ACQUIRING:
return {
...state,
acquiringDevice: true,
};
case CONNECT.STOP_ACQUIRING:
return {
...state,
acquiringDevice: false,
};
default: default:
return state; return state;

View File

@ -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`;

View File

@ -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();
});
});
});

View File

@ -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))); export const isSelectedDevice = (current: ?TrezorDevice, device: ?TrezorDevice): boolean => !!((current && device && (current.path === device.path && current.instance === device.instance)));
// find device by id and state // find device by id and state
export const findDevice = (devices: Array<TrezorDevice>, deviceId: string, deviceState: string, instance: ?number): ?TrezorDevice => devices.find((d) => { export const findDevice = (devices: Array<TrezorDevice>, deviceId: string, deviceState: string /*, instance: ?number*/): ?TrezorDevice => devices.find((d) => {
// TODO: && (instance && d.instance === instance) // TODO: && (instance && d.instance === instance)
if (d.features && d.features.device_id === deviceId && d.state === deviceState) { if (d.features && d.features.device_id === deviceId && d.state === deviceState) {
return true; return true;
@ -116,21 +116,54 @@ export const getWeb3 = (state: State): ?Web3Instance => {
return state.web3.find(w3 => w3.network === locationState.network); return state.web3.find(w3 => w3.network === locationState.network);
}; };
export const observeChanges = (prev: ?(Object | Array<any>), current: ?(Object | Array<any>), fields?: Array<string>): boolean => { export const observeChanges = (prev: ?Object, current: ?Object, filter?: {[k: string]: Array<string>}): boolean => {
if (prev !== current) { // 1. both objects are the same (solves simple types like string, boolean and number)
// 1. one of the objects is null/undefined if (prev === current) return false;
if (!prev || !current) return true; // 2. one of the objects is null/undefined
// 2. object are Arrays and they have different length if (!prev || !current) return true;
if (Array.isArray(prev) && Array.isArray(current)) return prev.length !== current.length;
// 3. no nested field to check const prevType = Object.prototype.toString.call(prev);
if (!Array.isArray(fields)) return true; const currentType = Object.prototype.toString.call(current);
// 4. validate nested field // 3. one of the objects has different type then other
if (prev instanceof Object && current instanceof Object) { if (prevType !== currentType) return true;
for (let i = 0; i < fields.length; i++) {
const key = fields[i]; if (currentType === '[object Array]') {
if (prev[key] !== current[key]) return true; // 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; return false;
}; };

View File

@ -110,9 +110,12 @@ const LocalStorageService: Middleware = (api: MiddlewareAPI) => (next: Middlewar
case SEND.TX_COMPLETE: case SEND.TX_COMPLETE:
case PENDING.TX_RESOLVED: case PENDING.TX_RESOLVED:
case PENDING.TX_NOT_FOUND: case PENDING.TX_REJECTED:
save(api.dispatch, api.getState); save(api.dispatch, api.getState);
break; break;
default:
return action;
} }
return action; return action;

View File

@ -21,7 +21,7 @@ import type {
/** /**
* Middleware * Middleware
*/ */
const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => (action: Action): Action => { const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => async (action: Action): Promise<Action> => {
const prevState = api.getState(); const prevState = api.getState();
// Application live cycle starts HERE! // Application live cycle starts HERE!
@ -88,14 +88,14 @@ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispa
api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state)); api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state));
} }
// observe send form props changes // observe common values in WallerReducer
api.dispatch(SendFormActionActions.observe(prevState, action)); if (!await api.dispatch(WalletActions.observe(prevState, action))) {
// if "selectedDevice" didn't change observe common values in SelectedAccountReducer
// update common values in WallerReducer if (!await api.dispatch(SelectedAccountActions.observe(prevState, action))) {
api.dispatch(WalletActions.updateSelectedValues(prevState, action)); // if "selectedAccount" didn't change observe send form props changes
api.dispatch(SendFormActionActions.observe(prevState, action));
// update common values in SelectedAccountReducer }
api.dispatch(SelectedAccountActions.updateSelectedValues(prevState, action)); }
return action; return action;
}; };

View File

@ -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 }) => (
<Wrapper>
<Notification
title="Initialization error"
message={props.error || ''}
type="error"
cancelable={false}
/>
</Wrapper>
);
export default InitializationError;

View File

@ -14,6 +14,7 @@ import { H2 } from 'components/Heading';
import { isWebUSB } from 'utils/device'; import { isWebUSB } from 'utils/device';
import { FONT_SIZE } from 'config/variables'; import { FONT_SIZE } from 'config/variables';
import InitializationError from './components/InitializationError';
import BrowserNotSupported from './components/BrowserNotSupported'; import BrowserNotSupported from './components/BrowserNotSupported';
import ConnectDevice from './components/ConnectDevice'; import ConnectDevice from './components/ConnectDevice';
import InstallBridge from './components/InstallBridge'; import InstallBridge from './components/InstallBridge';
@ -77,13 +78,13 @@ export default (props: Props) => {
const bridgeRoute: boolean = props.router.location.state.hasOwnProperty('bridge'); const bridgeRoute: boolean = props.router.location.state.hasOwnProperty('bridge');
const deviceLabel = props.wallet.disconnectRequest ? props.wallet.disconnectRequest.label : ''; 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 shouldShowConnectDevice = props.wallet.ready && devices.length < 1;
const shouldShowDisconnectDevice = !!props.wallet.disconnectRequest; const shouldShowDisconnectDevice = !!props.wallet.disconnectRequest;
const shouldShowUnsupportedBrowser = browserState.supported === false; const shouldShowUnsupportedBrowser = browserState.supported === false;
const isLoading = !shouldShowInstallBridge && !shouldShowConnectDevice && !shouldShowUnsupportedBrowser && !localStorageError; const isLoading = !shouldShowInitializationError && !shouldShowInstallBridge && !shouldShowConnectDevice && !shouldShowUnsupportedBrowser && !localStorageError;
return ( return (
<LandingWrapper> <LandingWrapper>
{isLoading && <LandingLoader text="Loading" size={100} />} {isLoading && <LandingLoader text="Loading" size={100} />}
@ -98,10 +99,9 @@ export default (props: Props) => {
/> />
)} )}
<Notifications /> <Notifications />
{shouldShowInitializationError && <InitializationError error={connectError} />}
<Log /> <Log />
<LandingContent> <LandingContent>
{shouldShowUnsupportedBrowser && <BrowserNotSupported />} {shouldShowUnsupportedBrowser && <BrowserNotSupported />}
{shouldShowInstallBridge && <InstallBridge browserState={browserState} />} {shouldShowInstallBridge && <InstallBridge browserState={browserState} />}

View File

@ -79,7 +79,7 @@ type TransitionMenuProps = {
children?: React.Node; children?: React.Node;
} }
// TransitionMenu needs to dispatch window.resize even // TransitionMenu needs to dispatch window.resize event
// in order to StickyContainer be recalculated // in order to StickyContainer be recalculated
const TransitionMenu = (props: TransitionMenuProps): React$Element<TransitionGroup> => ( const TransitionMenu = (props: TransitionMenuProps): React$Element<TransitionGroup> => (
<TransitionGroupWrapper component="div" className="transition-container"> <TransitionGroupWrapper component="div" className="transition-container">
@ -108,20 +108,15 @@ type State = {
class LeftNavigation extends React.PureComponent<Props, State> { class LeftNavigation extends React.PureComponent<Props, State> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
const { location } = this.props.router;
const hasNetwork = location && location.state && location.state.network;
this.state = { this.state = {
animationType: null, animationType: hasNetwork ? 'slide-left' : null,
shouldRenderDeviceSelection: false, shouldRenderDeviceSelection: false,
clicked: false, clicked: false,
}; };
} }
componentDidMount() {
this.setState({
animationType: null,
shouldRenderDeviceSelection: false,
});
}
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
const { dropdownOpened, selectedDevice } = nextProps.wallet; const { dropdownOpened, selectedDevice } = nextProps.wallet;
const hasNetwork = nextProps.location.state && nextProps.location.state.network; const hasNetwork = nextProps.location.state && nextProps.location.state.network;

View File

@ -12,13 +12,15 @@ import type { State } from 'flowtype';
import Header from 'components/Header'; import Header from 'components/Header';
import Footer from 'components/Footer'; import Footer from 'components/Footer';
import ModalContainer from 'components/modals'; import ModalContainer from 'components/modals';
import Notifications from 'components/Notification'; import ContextNotifications from 'components/notifications/Context';
import Log from 'components/Log'; import Log from 'components/Log';
import LeftNavigation from './components/LeftNavigation/Container'; import LeftNavigation from './components/LeftNavigation/Container';
import TopNavigationAccount from './components/TopNavigationAccount'; import TopNavigationAccount from './components/TopNavigationAccount';
import TopNavigationDeviceSettings from './components/TopNavigationDeviceSettings'; import TopNavigationDeviceSettings from './components/TopNavigationDeviceSettings';
type WalletContainerProps = { type WalletContainerProps = {
wallet: $ElementType<State, 'wallet'>, wallet: $ElementType<State, 'wallet'>,
children?: React.Node children?: React.Node
@ -89,7 +91,7 @@ const Wallet = (props: WalletContainerProps) => (
<Route path="/device/:device/network/:network/account/:account" component={TopNavigationAccount} /> <Route path="/device/:device/network/:network/account/:account" component={TopNavigationAccount} />
<Route path="/device/:device/device-settings" component={TopNavigationDeviceSettings} /> <Route path="/device/:device/device-settings" component={TopNavigationDeviceSettings} />
</Navigation> </Navigation>
<Notifications /> <ContextNotifications />
<Log /> <Log />
<Body> <Body>
{ props.children } { props.children }

View File

@ -10,8 +10,6 @@ import colors from 'config/colors';
import Tooltip from 'components/Tooltip'; import Tooltip from 'components/Tooltip';
import { QRCode } from 'react-qr-svg'; import { QRCode } from 'react-qr-svg';
import SelectedAccount from 'views/Wallet/components/SelectedAccount';
import { FONT_SIZE, FONT_WEIGHT, FONT_FAMILY } from 'config/variables'; import { FONT_SIZE, FONT_WEIGHT, FONT_FAMILY } from 'config/variables';
import type { Props } from './Container'; import type { Props } from './Container';
@ -154,87 +152,85 @@ const AccountReceive = (props: Props) => {
const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified; const isAddressHidden = !isAddressVerifying && !addressVerified && !addressUnverified;
return ( return (
<SelectedAccount {...props}> <Wrapper>
<Wrapper> <StyledH2>Receive Ethereum or tokens</StyledH2>
<StyledH2>Receive Ethereum or tokens</StyledH2> <AddressWrapper
<AddressWrapper isShowingQrCode={addressVerified || addressUnverified}
isShowingQrCode={addressVerified || addressUnverified} >
> {((addressVerified || addressUnverified) && !isAddressVerifying) && (
{((addressVerified || addressUnverified) && !isAddressVerifying) && ( <Tooltip
<Tooltip placement="bottomRight"
placement="bottomRight" content={(
content={( <React.Fragment>
<React.Fragment> {addressUnverified ? (
{addressUnverified ? ( <React.Fragment>
<React.Fragment> Unverified address.
Unverified address. <br />
<br /> {device.connected && device.available ? 'Show on TREZOR' : 'Connect your TREZOR to verify it.'}
{device.connected && device.available ? 'Show on TREZOR' : 'Connect your TREZOR to verify it.'} </React.Fragment>
</React.Fragment> ) : (
) : ( <React.Fragment>
<React.Fragment> {device.connected ? 'Show on TREZOR' : 'Connect your TREZOR to verify address.'}
{device.connected ? 'Show on TREZOR' : 'Connect your TREZOR to verify address.'} </React.Fragment>
</React.Fragment> )}
)} </React.Fragment>
</React.Fragment> )}
)} >
> <EyeButton
<EyeButton isTransparent
isTransparent
onClick={() => props.showAddress(account.addressPath)}
>
<Icon
icon={addressUnverified ? ICONS.EYE_CROSSED : ICONS.EYE}
color={addressUnverified ? colors.ERROR_PRIMARY : colors.TEXT_PRIMARY}
/>
</EyeButton>
</Tooltip>
)}
<ValueWrapper
isHidden={isAddressHidden}
isVerifying={isAddressVerifying}
>{address}
</ValueWrapper>
{isAddressVerifying && (
<React.Fragment>
<ArrowUp />
<AddressInfoText>
<Icon
icon={ICONS.T1}
color={colors.WHITE}
/>
Check address on your Trezor
</AddressInfoText>
</React.Fragment>
)}
{/* {isAddressVerifying && (
<AddressInfoText>{account.network} account #{account.index + 1}</AddressInfoText>
)} */}
{(addressVerified || addressUnverified) && (
<QRCode
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
style={qrCodeStyle}
value={account.address}
/>
)}
{!(addressVerified || addressUnverified) && (
<ShowAddressButton
onClick={() => props.showAddress(account.addressPath)} onClick={() => props.showAddress(account.addressPath)}
isDisabled={device.connected && !discovery.completed}
> >
<ShowAddressIcon <Icon
icon={ICONS.EYE} icon={addressUnverified ? ICONS.EYE_CROSSED : ICONS.EYE}
color={addressUnverified ? colors.ERROR_PRIMARY : colors.TEXT_PRIMARY}
/>
</EyeButton>
</Tooltip>
)}
<ValueWrapper
isHidden={isAddressHidden}
isVerifying={isAddressVerifying}
>{address}
</ValueWrapper>
{isAddressVerifying && (
<React.Fragment>
<ArrowUp />
<AddressInfoText>
<Icon
icon={ICONS.T1}
color={colors.WHITE} color={colors.WHITE}
/> />
Show full address Check address on your Trezor
</ShowAddressButton> </AddressInfoText>
)} </React.Fragment>
</AddressWrapper> )}
</Wrapper> {/* {isAddressVerifying && (
</SelectedAccount> <AddressInfoText>{account.network} account #{account.index + 1}</AddressInfoText>
)} */}
{(addressVerified || addressUnverified) && (
<QRCode
bgColor="#FFFFFF"
fgColor="#000000"
level="Q"
style={qrCodeStyle}
value={account.address}
/>
)}
{!(addressVerified || addressUnverified) && (
<ShowAddressButton
onClick={() => props.showAddress(account.addressPath)}
isDisabled={device.connected && !discovery.completed}
>
<ShowAddressIcon
icon={ICONS.EYE}
color={colors.WHITE}
/>
Show full address
</ShowAddressButton>
)}
</AddressWrapper>
</Wrapper>
); );
}; };
export default AccountReceive; export default AccountReceive;

View File

@ -12,7 +12,6 @@ import { FONT_SIZE, FONT_WEIGHT, TRANSITION } from 'config/variables';
import colors from 'config/colors'; import colors from 'config/colors';
import P from 'components/Paragraph'; import P from 'components/Paragraph';
import { H2 } from 'components/Heading'; import { H2 } from 'components/Heading';
import SelectedAccount from 'views/Wallet/components/SelectedAccount';
import type { Token } from 'flowtype'; import type { Token } from 'flowtype';
import AdvancedForm from './components/AdvancedForm'; import AdvancedForm from './components/AdvancedForm';
import PendingTransactions from './components/PendingTransactions'; import PendingTransactions from './components/PendingTransactions';
@ -191,6 +190,7 @@ const AccountSend = (props: Props) => {
network, network,
discovery, discovery,
tokens, tokens,
shouldRender,
} = props.selectedAccount; } = props.selectedAccount;
const { const {
address, address,
@ -219,6 +219,8 @@ const AccountSend = (props: Props) => {
onSend, onSend,
} = props.sendFormActions; } = props.sendFormActions;
if (!device || !account || !discovery || !network || !shouldRender) return null;
const isCurrentCurrencyToken = networkSymbol !== currency; const isCurrentCurrencyToken = networkSymbol !== currency;
let selectedTokenBalance = 0; let selectedTokenBalance = 0;
@ -227,8 +229,6 @@ const AccountSend = (props: Props) => {
selectedTokenBalance = selectedToken.balance; 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 isSendButtonDisabled: boolean = Object.keys(errors).length > 0 || total === '0' || amount.length === 0 || address.length === 0 || sending;
let sendButtonText: string = 'Send'; let sendButtonText: string = 'Send';
if (networkSymbol !== currency && amount.length > 0 && !errors.amount) { if (networkSymbol !== currency && amount.length > 0 && !errors.amount) {
@ -252,157 +252,155 @@ const AccountSend = (props: Props) => {
const isAdvancedSettingsHidden = !advanced; const isAdvancedSettingsHidden = !advanced;
return ( return (
<SelectedAccount {...props}> <Wrapper>
<Wrapper> <StyledH2>Send Ethereum or tokens</StyledH2>
<StyledH2>Send Ethereum or tokens</StyledH2> <InputRow>
<InputRow> <Input
<Input state={getAddressInputState(address, errors.address, warnings.address)}
state={getAddressInputState(address, errors.address, warnings.address)} autoComplete="off"
autoComplete="off" autoCorrect="off"
autoCorrect="off" autoCapitalize="off"
autoCapitalize="off" spellCheck="false"
spellCheck="false" topLabel="Address"
topLabel="Address" bottomText={errors.address || warnings.address || infos.address}
bottomText={errors.address || warnings.address || infos.address} value={address}
value={address} onChange={event => onAddressChange(event.target.value)}
onChange={event => onAddressChange(event.target.value)} />
/> </InputRow>
</InputRow>
<InputRow> <InputRow>
<Input <Input
state={getAmountInputState(errors.amount, warnings.amount)} state={getAmountInputState(errors.amount, warnings.amount)}
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
topLabel={( topLabel={(
<AmountInputLabelWrapper> <AmountInputLabelWrapper>
<AmountInputLabel>Amount</AmountInputLabel> <AmountInputLabel>Amount</AmountInputLabel>
{(isCurrentCurrencyToken && selectedToken) && ( {(isCurrentCurrencyToken && selectedToken) && (
<AmountInputLabel>You have: {selectedTokenBalance} {selectedToken.symbol}</AmountInputLabel> <AmountInputLabel>You have: {selectedTokenBalance} {selectedToken.symbol}</AmountInputLabel>
)} )}
</AmountInputLabelWrapper> </AmountInputLabelWrapper>
)}
value={amount}
onChange={event => onAmountChange(event.target.value)}
bottomText={errors.amount || warnings.amount || infos.amount}
sideAddons={[
(
<SetMaxAmountButton
key="icon"
onClick={() => onSetMax()}
isActive={setMax}
>
{!setMax && (
<Icon
icon={ICONS.TOP}
size={25}
color={colors.TEXT_SECONDARY}
/>
)}
{setMax && (
<Icon
icon={ICONS.CHECKED}
size={25}
color={colors.WHITE}
/>
)}
Set max
</SetMaxAmountButton>
),
(
<CurrencySelect
key="currency"
isSearchable={false}
isClearable={false}
defaultValue={tokensSelectData[0]}
isDisabled={tokensSelectData.length < 2}
onChange={onCurrencyChange}
options={tokensSelectData}
/>
),
]}
/>
</InputRow>
<InputRow>
<FeeLabelWrapper>
<FeeLabel>Fee</FeeLabel>
{gasPriceNeedsUpdate && (
<UpdateFeeWrapper>
<Icon
icon={ICONS.WARNING}
color={colors.WARNING_PRIMARY}
size={20}
/>
Recommended fees updated. <StyledLink onClick={updateFeeLevels} isGreen>Click here to use them</StyledLink>
</UpdateFeeWrapper>
)}
</FeeLabelWrapper>
<Select
isSearchable={false}
isClearable={false}
value={selectedFeeLevel}
onChange={onFeeLevelChange}
options={feeLevels}
formatOptionLabel={option => (
<FeeOptionWrapper>
<P>{option.value}</P>
<P>{option.label}</P>
</FeeOptionWrapper>
)}
/>
</InputRow>
<ToggleAdvancedSettingsWrapper
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
>
<ToggleAdvancedSettingsButton
isTransparent
onClick={toggleAdvanced}
>
Advanced settings
<AdvancedSettingsIcon
icon={ICONS.ARROW_DOWN}
color={colors.TEXT_SECONDARY}
size={24}
isActive={advanced}
canAnimate
/>
</ToggleAdvancedSettingsButton>
{isAdvancedSettingsHidden && (
<SendButton
isDisabled={isSendButtonDisabled}
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
)} )}
</ToggleAdvancedSettingsWrapper> value={amount}
onChange={event => onAmountChange(event.target.value)}
bottomText={errors.amount || warnings.amount || infos.amount}
sideAddons={[
(
<SetMaxAmountButton
key="icon"
onClick={() => onSetMax()}
isActive={setMax}
>
{!setMax && (
<Icon
icon={ICONS.TOP}
size={25}
color={colors.TEXT_SECONDARY}
/>
)}
{setMax && (
<Icon
icon={ICONS.CHECKED}
size={25}
color={colors.WHITE}
/>
)}
Set max
</SetMaxAmountButton>
),
(
<CurrencySelect
key="currency"
isSearchable={false}
isClearable={false}
defaultValue={tokensSelectData[0]}
isDisabled={tokensSelectData.length < 2}
onChange={onCurrencyChange}
options={tokensSelectData}
/>
),
]}
/>
</InputRow>
{advanced && ( <InputRow>
<AdvancedForm {...props}> <FeeLabelWrapper>
<SendButton <FeeLabel>Fee</FeeLabel>
isDisabled={isSendButtonDisabled} {gasPriceNeedsUpdate && (
onClick={() => onSend()} <UpdateFeeWrapper>
> <Icon
{sendButtonText} icon={ICONS.WARNING}
</SendButton> color={colors.WARNING_PRIMARY}
</AdvancedForm> size={20}
)} />
Recommended fees updated. <StyledLink onClick={updateFeeLevels} isGreen>Click here to use them</StyledLink>
</UpdateFeeWrapper>
)}
</FeeLabelWrapper>
<Select
isSearchable={false}
isClearable={false}
value={selectedFeeLevel}
onChange={onFeeLevelChange}
options={feeLevels}
formatOptionLabel={option => (
<FeeOptionWrapper>
<P>{option.value}</P>
<P>{option.label}</P>
</FeeOptionWrapper>
)}
/>
</InputRow>
{props.selectedAccount.pending.length > 0 && ( <ToggleAdvancedSettingsWrapper
<PendingTransactions isAdvancedSettingsHidden={isAdvancedSettingsHidden}
pending={props.selectedAccount.pending} >
tokens={props.selectedAccount.tokens} <ToggleAdvancedSettingsButton
network={network} isTransparent
onClick={toggleAdvanced}
>
Advanced settings
<AdvancedSettingsIcon
icon={ICONS.ARROW_DOWN}
color={colors.TEXT_SECONDARY}
size={24}
isActive={advanced}
canAnimate
/> />
</ToggleAdvancedSettingsButton>
{isAdvancedSettingsHidden && (
<SendButton
isDisabled={isSendButtonDisabled}
isAdvancedSettingsHidden={isAdvancedSettingsHidden}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
)} )}
</Wrapper> </ToggleAdvancedSettingsWrapper>
</SelectedAccount>
{advanced && (
<AdvancedForm {...props}>
<SendButton
isDisabled={isSendButtonDisabled}
onClick={() => onSend()}
>
{sendButtonText}
</SendButton>
</AdvancedForm>
)}
{props.selectedAccount.pending.length > 0 && (
<PendingTransactions
pending={props.selectedAccount.pending}
tokens={props.selectedAccount.tokens}
network={network}
/>
)}
</Wrapper>
); );
}; };

View File

@ -11,7 +11,6 @@ import Tooltip from 'components/Tooltip';
import CoinLogo from 'components/images/CoinLogo'; import CoinLogo from 'components/images/CoinLogo';
import * as stateUtils from 'reducers/utils'; import * as stateUtils from 'reducers/utils';
import SelectedAccount from 'views/Wallet/components/SelectedAccount';
import Link from 'components/Link'; import Link from 'components/Link';
import AccountBalance from './components/AccountBalance'; import AccountBalance from './components/AccountBalance';
import AddedToken from './components/AddedToken'; import AddedToken from './components/AddedToken';
@ -65,17 +64,18 @@ const AccountSummary = (props: Props) => {
network, network,
tokens, tokens,
pending, pending,
shouldRender,
} = props.selectedAccount; } = props.selectedAccount;
// flow // flow
if (!device || !account || !network) return <SelectedAccount {...props} />; if (!device || !account || !network || !shouldRender) return null;
const explorerLink: string = `${network.explorer.address}${account.address}`; const explorerLink: string = `${network.explorer.address}${account.address}`;
const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol); const pendingAmount: BigNumber = stateUtils.getPendingAmount(pending, network.symbol);
const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10); const balance: string = new BigNumber(account.balance).minus(pendingAmount).toString(10);
return ( return (
<SelectedAccount {...props}> <React.Fragment>
<AccountHeading> <AccountHeading>
<AccountName> <AccountName>
<StyledCoinLogo coinNetwork={account.network} /> <StyledCoinLogo coinNetwork={account.network} />
@ -155,7 +155,7 @@ const AccountSummary = (props: Props) => {
/> />
))} ))}
</AddedTokensWrapper> </AddedTokensWrapper>
</SelectedAccount> </React.Fragment>
); );
}; };

View File

@ -46,7 +46,7 @@ const Acquire = (props: Props) => {
export default connect( export default connect(
(state: State) => ({ (state: State) => ({
acquiring: state.connect.acquiring, acquiring: state.connect.acquiringDevice,
}), }),
(dispatch: Dispatch) => ({ (dispatch: Dispatch) => ({
acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch), acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch),

View File

@ -1,14 +1,8 @@
/* @flow */ /* @flow */
import React from 'react'; import React from 'react';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import styled from 'styled-components'; import styled from 'styled-components';
import { Notification } from 'components/Notification'; import { Notification } from 'components/Notification';
import * as TrezorConnectActions from 'actions/TrezorConnectActions';
import type { State, Dispatch } from 'flowtype';
const Wrapper = styled.div``; const Wrapper = styled.div``;
@ -23,11 +17,4 @@ const UnreadableDevice = () => (
</Wrapper> </Wrapper>
); );
export default connect( export default UnreadableDevice;
(state: State) => ({
acquiring: state.connect.acquiring,
}),
(dispatch: Dispatch) => ({
acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch),
}),
)(UnreadableDevice);