diff --git a/.eslintrc b/.eslintrc index 5c87b414..7c00e41a 100644 --- a/.eslintrc +++ b/.eslintrc @@ -12,6 +12,7 @@ "jest": true }, "rules": { + "no-use-before-define": 0, "no-plusplus": 0, "class-methods-use-this": 0, "react/require-default-props": 0, diff --git a/src/actions/LocalStorageActions.js b/src/actions/LocalStorageActions.js index ae7f7858..5e6cef06 100644 --- a/src/actions/LocalStorageActions.js +++ b/src/actions/LocalStorageActions.js @@ -21,7 +21,7 @@ export type StorageAction = { type: typeof STORAGE.READY, config: Config, tokens: TokensCollection, - ERC20Abi: Array + ERC20Abi: Array } | { type: typeof STORAGE.SAVE, network: string, @@ -148,7 +148,6 @@ export function loadTokensFromJSON(): AsyncAction { }); } - dispatch({ type: STORAGE.READY, config, diff --git a/src/actions/RouterActions.js b/src/actions/RouterActions.js new file mode 100644 index 00000000..afa68ebe --- /dev/null +++ b/src/actions/RouterActions.js @@ -0,0 +1,304 @@ +/* @flow */ + +import { push, LOCATION_CHANGE } from 'react-router-redux'; +import { routes } from 'support/routes'; + +import type { + RouterLocationState, + Device, + TrezorDevice, + ThunkAction, + PayloadAction, + Dispatch, + GetState, +} from 'flowtype'; +import type { RouterAction } from 'react-router-redux'; + +/* +* Parse url string to RouterLocationState object (key/value) +*/ +export const pathToParams = (path: string): PayloadAction => (): RouterLocationState => { + // split url into parts + const parts: Array = path.split('/').slice(1); + const params: RouterLocationState = {}; + // return empty params + if (parts.length < 1 || path === '/') return params; + + // map parts to params by key/value + // assuming that url is in format: "/key/value" + for (let i = 0, len = parts.length; i < len; i += 2) { + params[parts[i]] = parts[i + 1] || parts[i]; + } + + // check for special case: /device/device-id:instance-id + if (params.hasOwnProperty('device')) { + const isClonedDevice: Array = params.device.split(':'); + if (isClonedDevice.length > 1) { + const [device, instance] = isClonedDevice; + params.device = device; + params.deviceInstance = instance; + } + } + return params; +}; + +/* +* RouterLocationState validation +* Check if requested device or network exists in reducers +*/ +export const paramsValidation = (params: RouterLocationState): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + // validate requested device + if (params.hasOwnProperty('device')) { + const { devices } = getState(); + + let device: ?TrezorDevice; + if (params.hasOwnProperty('deviceInstance')) { + device = devices.find(d => d.features && d.features.device_id === params.device && d.instance === parseInt(params.deviceInstance, 10)); + } else { + device = devices.find(d => d.path === params.device || (d.features && d.features.device_id === params.device)); + } + + if (!device) return false; + } + + // validate requested network + if (params.hasOwnProperty('network')) { + const { config } = getState().localStorage; + const coin = config.coins.find(c => c.network === params.network); + if (!coin) return false; + if (!params.account) return false; + } + + // validate requested account + // TODO: only if discovery on this network is completed + // if (params.hasOwnProperty('account')) { + + // } + return true; +}; + +/* +* Composing url string from given RouterLocationState object +* Filters unrecognized fields and sorting in correct order +*/ +export const paramsToPath = (params: RouterLocationState): PayloadAction => (): ?string => { + // get patterns (fields) from routes and sort them by complexity + const patterns: Array> = routes.map(r => r.fields).sort((a, b) => (a.length > b.length ? -1 : 1)); + + // find pattern + const keys: Array = Object.keys(params); + let patternToUse: ?Array; + let i: number; + for (i = 0; i < patterns.length; i++) { + const pattern = patterns[i]; + const match: Array = keys.filter(key => pattern.indexOf(key) >= 0); + if (match.length === pattern.length) { + patternToUse = pattern; + break; + } + } + + // pattern not found, redirect back + if (!patternToUse) return null; + + // compose url string from pattern + let url: string = ''; + patternToUse.forEach((field) => { + if (field === params[field]) { + // standalone (odd) fields + url += `/${field}`; + } else { + url += `/${field}/${params[field]}`; + if (field === 'device') { + if (params.hasOwnProperty('deviceInstance')) { + url += `:${params.deviceInstance}`; + } + } + } + }); + return url; +}; + +export const getValidUrl = (action: RouterAction): PayloadAction => (dispatch: Dispatch, getState: GetState): string => { + const { location } = getState().router; + + // redirect to landing page (loading screen) + // and wait until application is ready + if (!location) return '/'; + + const requestedUrl = action.payload.pathname; + // Corner case: LOCATION_CHANGE was called but pathname didn't changed (redirect action from RouterService) + if (requestedUrl === location.pathname) return requestedUrl; + + // Modal is opened + // redirect to previous url + if (getState().modal.opened) { + // Corner case: modal is opened and currentParams are still valid + // example 1 (valid blocking): url changed while passphrase modal opened but device is still connected (we want user to finish this action) + // example 2 (invalid blocking): url changes while passphrase modal opened because device disconnect + const currentParams = dispatch(pathToParams(location.pathname)); + const currentParamsAreValid = dispatch(paramsValidation(currentParams)); + if (currentParamsAreValid) { return location.pathname; } + } + + // there are no connected devices or application isn't ready or initialization error occurred + // redirect to landing page + const shouldBeLandingPage = getState().devices.length < 1 || !getState().wallet.ready || getState().connect.error !== null; + const landingPageUrl = dispatch(isLandingPageUrl(requestedUrl)); + if (shouldBeLandingPage) { + return !landingPageUrl ? '/' : requestedUrl; + } + + // Disallow displaying landing page + // redirect to previous url + if (!shouldBeLandingPage && landingPageUrl) { + return location.pathname; + } + + // Regular url change during application live cycle + const requestedParams = dispatch(pathToParams(requestedUrl)); + const requestedParamsAreValid: boolean = dispatch(paramsValidation(requestedParams)); + + // Requested params are not valid + // Neither device or network doesn't exists + if (!requestedParamsAreValid) { + return location.pathname; + } + + // Compose valid url from requested params + const composedUrl = dispatch(paramsToPath(requestedParams)); + return composedUrl || location.pathname; +}; + + +/* +* Utility used in "selectDevice" and "selectFirstAvailableDevice" +* sorting device array by "ts" (timestamp) field +*/ +const sortDevices = (devices: Array): Array => devices.sort((a, b) => { + if (!a.ts || !b.ts) { + return -1; + } + return a.ts > b.ts ? -1 : 1; +}); + +/* +* Compose url from given device object and redirect +*/ +export const selectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + let url: ?string; + if (!device.features) { + url = `/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`; + } else if (device.features.bootloader_mode) { + url = `/device/${device.path}/bootloader`; + } else if (!device.features.initialized) { + url = `/device/${device.features.device_id}/initialize`; + } else if (typeof device.instance === 'number') { + url = `/device/${device.features.device_id}:${device.instance}`; + } else { + url = `/device/${device.features.device_id}`; + // make sure that device is not TrezorDevice type + if (!device.hasOwnProperty('ts')) { + // it is device from trezor-connect triggered by DEVICE.CONNECT event + // need to lookup if there are unavailable instances + const available: Array = getState().devices.filter(d => d.path === device.path); + const latest: Array = sortDevices(available); + if (latest.length > 0 && latest[0].instance) { + url += `:${latest[0].instance}`; + } + } + } + + const currentParams: RouterLocationState = getState().router.location.state; + const requestedParams = dispatch(pathToParams(url)); + if (currentParams.device !== requestedParams.device || currentParams.deviceInstance !== requestedParams.deviceInstance) { + dispatch(goto(url)); + } +}; + +/* +* Try to find first available device using order: +* 1. First unacquired +* 2. First connected +* 3. Saved with latest timestamp +* OR redirect to landing page +*/ +export const selectFirstAvailableDevice = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + const { devices } = getState(); + if (devices.length > 0) { + const unacquired = devices.find(d => !d.features); + if (unacquired) { + dispatch(selectDevice(unacquired)); + } else { + const latest: Array = sortDevices(devices); + const firstConnected: ?TrezorDevice = latest.find(d => d.connected); + dispatch(selectDevice(firstConnected || latest[0])); + } + } else { + dispatch(gotoLandingPage()); + } +}; + +/* +* Internal method. redirect to given url +*/ +const goto = (url: string): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { + if (getState().router.location.pathname !== url) { + dispatch(push(url)); + } +}; + +/* +* Check if requested OR current url is landing page +*/ +export const isLandingPageUrl = ($url?: string): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + let url: ?string = $url; + if (typeof url !== 'string') { + url = getState().router.location.pathname; + } + // TODO: add more landing page cases/urls to config.json (like /tools etc) + return (url === '/' || url === '/bridge'); +}; + +/* +* Try to redirect to landing page +*/ +export const gotoLandingPage = (): ThunkAction => (dispatch: Dispatch): void => { + const isLandingPage = dispatch(isLandingPageUrl()); + if (!isLandingPage) { + dispatch(goto('/')); + } +}; + +/* +* Go to given device settings page +*/ +export const gotoDeviceSettings = (device: TrezorDevice): ThunkAction => (dispatch: Dispatch): void => { + if (device.features) { + const devUrl: string = `${device.features.device_id}${device.instance ? `:${device.instance}` : ''}`; + dispatch(goto(`/device/${devUrl}/settings`)); + } +}; + +/* +* Try to redirect to initial url +*/ +export const setInitialUrl = (): PayloadAction => (dispatch: Dispatch, getState: GetState): boolean => { + const { initialPathname } = getState().wallet; + if (typeof initialPathname === 'string' && !dispatch(isLandingPageUrl(initialPathname))) { + const valid = dispatch(getValidUrl({ + type: LOCATION_CHANGE, + payload: { + pathname: initialPathname, + hash: '', + search: '', + state: {}, + }, + })); + if (valid === initialPathname) { + dispatch(goto(valid)); + return true; + } + } + return false; +}; diff --git a/src/actions/SelectedAccountActions.js b/src/actions/SelectedAccountActions.js index 0bacf1b3..dd54a365 100644 --- a/src/actions/SelectedAccountActions.js +++ b/src/actions/SelectedAccountActions.js @@ -7,6 +7,9 @@ import * as SEND from 'actions/constants/send'; import * as NOTIFICATION from 'actions/constants/notification'; import * as PENDING from 'actions/constants/pendingTx'; +import * as SendFormActions from 'actions/SendFormActions'; +import * as SessionStorageActions from 'actions/SessionStorageActions'; + import * as stateUtils from 'reducers/utils'; import type { @@ -16,8 +19,6 @@ import type { Dispatch, State, } from 'flowtype'; -import * as SendFormActions from './SendFormActions'; - export type SelectedAccountAction = { type: typeof ACCOUNT.DISPOSE, @@ -42,17 +43,16 @@ export const updateSelectedValues = (prevState: State, action: Action): AsyncAct // SessionStorageActions.clear(location.pathname); } + if (prevState.sendForm !== state.sendForm) { + dispatch(SessionStorageActions.save()); + } + // handle devices state change (from trezor-connect events or location change) if (locationChange || prevState.accounts !== state.accounts || prevState.discovery !== state.discovery || prevState.tokens !== state.tokens || prevState.pending !== state.pending) { - if (locationChange) { - // dispose current account view - dispatch(dispose()); - } - const account = stateUtils.getSelectedAccount(state); const network = stateUtils.getSelectedNetwork(state); const discovery = stateUtils.getDiscoveryProcess(state); diff --git a/src/actions/TrezorConnectActions.js b/src/actions/TrezorConnectActions.js index 94f99347..7d718906 100644 --- a/src/actions/TrezorConnectActions.js +++ b/src/actions/TrezorConnectActions.js @@ -1,13 +1,11 @@ /* @flow */ import TrezorConnect, { - UI, DEVICE, DEVICE_EVENT, UI_EVENT, TRANSPORT_EVENT, BLOCKCHAIN_EVENT + DEVICE, DEVICE_EVENT, UI_EVENT, TRANSPORT_EVENT, BLOCKCHAIN_EVENT, } from 'trezor-connect'; import * as CONNECT from 'actions/constants/TrezorConnect'; import * as NOTIFICATION from 'actions/constants/notification'; -import * as WALLET from 'actions/constants/wallet'; import { getDuplicateInstanceNumber } from 'reducers/utils'; - -import { push } from 'react-router-redux'; +import * as RouterActions from 'actions/RouterActions'; import type { DeviceMessage, @@ -28,7 +26,6 @@ import type { AsyncAction, Device, TrezorDevice, - RouterLocationState, } from 'flowtype'; import * as DiscoveryActions from './DiscoveryActions'; @@ -79,19 +76,11 @@ export type TrezorConnectAction = { type: typeof CONNECT.STOP_ACQUIRING, }; - -const sortDevices = (devices: Array): Array => devices.sort((a, b) => { - if (!a.ts || !b.ts) { - return -1; - } - return a.ts > b.ts ? -1 : 1; -}); - export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { // set listeners TrezorConnect.on(DEVICE_EVENT, (event: DeviceMessage): void => { // post event to reducers - const type: DeviceMessageType = event.type; // assert flow type + const type: DeviceMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, device: event.payload, @@ -100,7 +89,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(UI_EVENT, (event: UiMessage): void => { // post event to reducers - const type: UiMessageType = event.type; // assert flow type + const type: UiMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, @@ -109,7 +98,7 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(TRANSPORT_EVENT, (event: TransportMessage): void => { // post event to reducers - const type: TransportMessageType = event.type; // assert flow type + const type: TransportMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, @@ -118,21 +107,22 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS TrezorConnect.on(BLOCKCHAIN_EVENT, (event: BlockchainMessage): void => { // post event to reducers - const type: BlockchainMessageType = event.type; // assert flow type + const type: BlockchainMessageType = event.type; // eslint-disable-line prefer-destructuring dispatch({ type, payload: event.payload, }); }); + /* global LOCAL */ // $FlowIssue LOCAL not declared - window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://sisyfos.trezor.io/connect/'; - // window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://connect.trezor.io/5/'; + window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://sisyfos.trezor.io/connect/'; // eslint-disable-line no-underscore-dangle + // window.__TREZOR_CONNECT_SRC = typeof LOCAL === 'string' ? LOCAL : 'https://connect.trezor.io/5/'; // eslint-disable-line no-underscore-dangle try { await TrezorConnect.init({ transportReconnect: true, - debug: true, + debug: false, popup: false, webusb: true, pendingTransportEvent: (getState().devices.length < 1), @@ -145,67 +135,11 @@ export const init = (): AsyncAction => async (dispatch: Dispatch, getState: GetS } }; -// selection from Aside dropdown button -// after device_connect event -// or after acquiring device -// device type could be local TrezorDevice or Device (from trezor-connect device_connect event) -export const onSelectDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { - // || device.isUsedElsewhere - - // switch to initial url and reset this value - - if (!device.features) { - dispatch(push(`/device/${device.path}/${device.type === 'unreadable' ? 'unreadable' : 'acquire'}`)); - } else if (device.features.bootloader_mode) { - dispatch(push(`/device/${device.path}/bootloader`)); - } else if (!device.features.initialized) { - dispatch(push(`/device/${device.features.device_id}/initialize`)); - } else if (typeof device.instance === 'number') { - dispatch(push(`/device/${device.features.device_id}:${device.instance}`)); - } else { - const deviceId: string = device.features.device_id; - const urlParams: RouterLocationState = getState().router.location.state; - // let url: string = `/device/${ device.features.device_id }/network/ethereum/account/0`; - let url: string = `/device/${deviceId}`; - let instance: ?number; - // check if device is not TrezorDevice type - if (!device.hasOwnProperty('ts')) { - // its device from trezor-connect (called in initConnectedDevice triggered by device_connect event) - // need to lookup if there are unavailable instances - const available: Array = getState().devices.filter(d => d.path === device.path); - const latest: Array = sortDevices(available); - - if (latest.length > 0 && latest[0].instance) { - url += `:${latest[0].instance}`; - instance = latest[0].instance; - } - } - // check if current location is not set to this device - //dispatch( push(`/device/${ device.features.device_id }/network/etc/account/0`) ); - - if (urlParams.deviceInstance !== instance || urlParams.device !== deviceId) { - dispatch(push(url)); - } - } -}; - -export const initConnectedDevice = (device: TrezorDevice | Device): ThunkAction => (dispatch: Dispatch/* , getState: GetState */): void => { - // const selected = getState().wallet.selectedDevice; - // if (!selected || (selected && selected.state)) { - dispatch(onSelectDevice(device)); - // } - // if (device.unacquired && selected && selected.path !== device.path && !selected.connected) { - // dispatch( onSelectDevice(device) ); - // } else if (!selected) { - // dispatch( onSelectDevice(device) ); - // } -}; - // called after backend was initialized // set listeners for connect/disconnect -export const postInit = (): ThunkAction => (dispatch: Dispatch, getState: GetState): void => { +export const postInit = (): ThunkAction => (dispatch: Dispatch): void => { const handleDeviceConnect = (device: Device) => { - dispatch(initConnectedDevice(device)); + dispatch(RouterActions.selectDevice(device)); }; TrezorConnect.off(DEVICE.CONNECT, handleDeviceConnect); @@ -214,58 +148,13 @@ export const postInit = (): ThunkAction => (dispatch: Dispatch, getState: GetSta TrezorConnect.on(DEVICE.CONNECT, handleDeviceConnect); TrezorConnect.on(DEVICE.CONNECT_UNACQUIRED, handleDeviceConnect); - const { devices } = getState(); - - const { initialPathname, initialParams } = getState().wallet; - - if (initialPathname) { - dispatch({ - type: WALLET.SET_INITIAL_URL, - // pathname: null, - // params: null - }); - } - - if (devices.length > 0) { - const unacquired: ?TrezorDevice = devices.find(d => d.features); - if (unacquired) { - dispatch(onSelectDevice(unacquired)); - } else { - const latest: Array = sortDevices(devices); - const firstConnected: ?TrezorDevice = latest.find(d => d.connected); - dispatch(onSelectDevice(firstConnected || latest[0])); - - // TODO - if (initialParams) { - if (!initialParams.hasOwnProperty('network') && initialPathname !== getState().router.location.pathname) { - // dispatch( push(initialPathname) ); - } - } - } + // try to redirect to initial url + if (!dispatch(RouterActions.setInitialUrl())) { + // if initial redirection fails try to switch to first available device + dispatch(RouterActions.selectFirstAvailableDevice()); } }; -export const switchToFirstAvailableDevice = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { - const { devices } = getState(); - if (devices.length > 0) { - // TODO: Priority: - // 1. First Unacquired - // 2. First connected - // 3. Saved with latest timestamp - const unacquired = devices.find(d => !d.features); - if (unacquired) { - dispatch(initConnectedDevice(unacquired)); - } else { - const latest: Array = sortDevices(devices); - const firstConnected: ?TrezorDevice = latest.find(d => d.connected); - dispatch(onSelectDevice(firstConnected || latest[0])); - } - } else { - dispatch(push('/')); - } -}; - - export const getSelectedDeviceState = (): AsyncAction => async (dispatch: Dispatch, getState: GetState): Promise => { const selected = getState().wallet.selectedDevice; if (selected @@ -345,7 +234,7 @@ export const coinChanged = (network: ?string): ThunkAction => (dispatch: Dispatc }; export function reload(): AsyncAction { - return async (dispatch: Dispatch, getState: GetState): Promise => { + return async (): Promise => { }; } @@ -391,13 +280,6 @@ export function acquire(): AsyncAction { }; } -export const gotoDeviceSettings = (device: TrezorDevice): ThunkAction => (dispatch: Dispatch): void => { - if (device.features) { - const devUrl: string = `${device.features.device_id}${device.instance ? `:${device.instance}` : ''}`; - dispatch(push(`/device/${devUrl}/settings`)); - } -}; - // called from Aside - device menu (forget single instance) export const forget = (device: TrezorDevice): Action => ({ type: CONNECT.FORGET_REQUEST, diff --git a/src/flowtype/index.js b/src/flowtype/index.js index ece6a996..6c138e58 100644 --- a/src/flowtype/index.js +++ b/src/flowtype/index.js @@ -6,9 +6,9 @@ import type { MiddlewareAPI as ReduxMiddlewareAPI, Middleware as ReduxMiddleware, ThunkAction as ReduxThunkAction, + PayloadAction as ReduxPayloadAction, AsyncAction as ReduxAsyncAction, PromiseAction as ReduxPromiseAction, - ThunkDispatch as ReduxThunkDispatch, PlainDispatch as ReduxPlainDispatch, } from 'redux'; @@ -164,6 +164,7 @@ export type MiddlewareAPI = ReduxMiddlewareAPI; export type Middleware = ReduxMiddleware; export type ThunkAction = ReduxThunkAction; +export type PayloadAction = ReduxPayloadAction; export type AsyncAction = ReduxAsyncAction; export type PromiseAction = ReduxPromiseAction; diff --git a/src/flowtype/npm/redux_v3.x.x.js b/src/flowtype/npm/redux_v3.x.x.js index cdd88212..a6b06010 100644 --- a/src/flowtype/npm/redux_v3.x.x.js +++ b/src/flowtype/npm/redux_v3.x.x.js @@ -2,7 +2,6 @@ declare module 'redux' { /* - S = State A = Action D = Dispatch @@ -16,28 +15,25 @@ declare module 'redux' { declare export type ThunkAction = (dispatch: ReduxDispatch, getState: () => S) => void; declare export type AsyncAction = (dispatch: ReduxDispatch, getState: () => S) => Promise; declare export type PromiseAction = (dispatch: ReduxDispatch, getState: () => S) => Promise; + declare export type PayloadAction = (dispatch: ReduxDispatch, getState: () => S) => R; declare export type ThunkDispatch = (action: ThunkAction) => void; declare export type AsyncDispatch = (action: AsyncAction) => Promise; declare export type PromiseDispatch = (action: PromiseAction) => Promise; + declare export type PayloadDispatch = (action: PayloadAction) => R; declare export type PlainDispatch}> = DispatchAPI; /* NEW: Dispatch is now a combination of these different dispatch types */ - declare export type ReduxDispatch = PlainDispatch & ThunkDispatch & AsyncDispatch & PromiseDispatch; + declare export type ReduxDispatch = PlainDispatch & ThunkDispatch & AsyncDispatch & PromiseDispatch & PayloadDispatch; declare export type MiddlewareAPI = { - // dispatch: Dispatch; - // dispatch: (action: A | ThunkAction) => A | void; - // dispatch: PlainDispatch | () => A; - // dispatch: ( A | ThunkAction | void ) => void; dispatch: ReduxDispatch; - // dispatch: Dispatch; - // dispatch: D; getState(): S; }; declare export type Middleware = (api: MiddlewareAPI) => - (next: PlainDispatch) => PlainDispatch; + (next: PlainDispatch) => + (PlainDispatch | (action: A) => Promise); declare export type Store> = { // rewrite MiddlewareAPI members in order to get nicer error messages (intersections produce long messages) diff --git a/src/reducers/LocalStorageReducer.js b/src/reducers/LocalStorageReducer.js index 47e1c9d4..27b3d9e2 100644 --- a/src/reducers/LocalStorageReducer.js +++ b/src/reducers/LocalStorageReducer.js @@ -66,7 +66,6 @@ export type CustomBackend = { url: string; } - export type State = { initialized: boolean; error: ?string; diff --git a/src/reducers/SelectedAccountReducer.js b/src/reducers/SelectedAccountReducer.js index 0500fb13..68fb56d6 100644 --- a/src/reducers/SelectedAccountReducer.js +++ b/src/reducers/SelectedAccountReducer.js @@ -1,6 +1,4 @@ /* @flow */ - - import * as ACCOUNT from 'actions/constants/account'; import type { @@ -14,7 +12,6 @@ import type { export type State = { location?: string; - account: ?Account; network: ?Coin; tokens: Array, diff --git a/src/reducers/WalletReducer.js b/src/reducers/WalletReducer.js index 5eb5698c..bdcd0d15 100644 --- a/src/reducers/WalletReducer.js +++ b/src/reducers/WalletReducer.js @@ -12,17 +12,18 @@ import type { Action, RouterLocationState, TrezorDevice } from 'flowtype'; type State = { ready: boolean; + unloading: boolean; online: boolean; dropdownOpened: boolean; initialParams: ?RouterLocationState; initialPathname: ?string; disconnectRequest: ?TrezorDevice; - selectedDevice: ?TrezorDevice; } const initialState: State = { ready: false, + unloading: false, online: navigator.onLine, dropdownOpened: false, initialParams: null, @@ -33,6 +34,12 @@ const initialState: State = { export default function wallet(state: State = initialState, action: Action): State { switch (action.type) { + case WALLET.ON_BEFORE_UNLOAD: + return { + ...state, + unloading: true, + }; + case WALLET.SET_INITIAL_URL: return { ...state, diff --git a/src/services/RouterService.js b/src/services/RouterService.js index 92ba3b4d..fac7c2b8 100644 --- a/src/services/RouterService.js +++ b/src/services/RouterService.js @@ -1,156 +1,46 @@ /* @flow */ -import { LOCATION_CHANGE/* , replace */ } from 'react-router-redux'; -import * as CONNECT from 'actions/constants/TrezorConnect'; -import * as WALLET from 'actions/constants/wallet'; -import * as NotificationActions from 'actions/NotificationActions'; +import { LOCATION_CHANGE, replace } from 'react-router-redux'; +import * as RouterActions from 'actions/RouterActions'; import type { Middleware, MiddlewareAPI, MiddlewareDispatch, Action, - RouterLocationState, - TrezorDevice, } from 'flowtype'; /** - * Middleware used for init application and managing router path. + * Redux Middleware used for managing router path + * This middleware couldn't use async/await because LOCATION_CHANGE action is also synchronized with RouterReducer (react-router-redux) */ -const pathToParams = (path: string): RouterLocationState => { - const urlParts: Array = path.split('/').slice(1); - const params: RouterLocationState = {}; - if (urlParts.length < 1 || path === '/') return params; - - for (let i = 0, len = urlParts.length; i < len; i += 2) { - params[urlParts[i]] = urlParts[i + 1] || urlParts[i]; - } - - if (params.hasOwnProperty('device')) { - const isClonedDevice: Array = params.device.split(':'); - if (isClonedDevice.length > 1) { - params.device = isClonedDevice[0]; - params.deviceInstance = isClonedDevice[1]; - } - } - - return params; -}; - -const validation = (api: MiddlewareAPI, params: RouterLocationState): boolean => { - if (params.hasOwnProperty('device')) { - const { devices } = api.getState(); - - let device: ?TrezorDevice; - if (params.hasOwnProperty('deviceInstance')) { - device = devices.find(d => d.features && d.features.device_id === params.device && d.instance === parseInt(params.deviceInstance, 10)); - } else { - device = devices.find(d => d.path === params.device || (d.features && d.features.device_id === params.device)); - } - - if (!device) return false; +const RouterService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => (action: Action): Action => { + // make sure that middleware should process this action + if (action.type !== LOCATION_CHANGE || api.getState().wallet.unloading) { + // pass action + return next(action); } - if (params.hasOwnProperty('network')) { - const { config } = api.getState().localStorage; - const coin = config.coins.find(c => c.network === params.network); - if (!coin) return false; - if (!params.account) return false; + // compose valid url + const validUrl = api.dispatch(RouterActions.getValidUrl(action)); + // override action state (to be stored in RouterReducer) + const override = action; + override.payload.state = api.dispatch(RouterActions.pathToParams(validUrl)); + const redirect = action.payload.pathname !== validUrl; + if (redirect) { + // override action pathname + override.payload.pathname = validUrl; } - // if (params.account) { - - // } - - return true; -}; - -let __unloading: boolean = false; - -const RouterService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => (action: Action): Action => { - if (action.type === WALLET.ON_BEFORE_UNLOAD) { - __unloading = true; - } else if (action.type === LOCATION_CHANGE && !__unloading) { - const { location } = api.getState().router; - const { devices } = api.getState(); - const { error } = api.getState().connect; - - const requestedParams: RouterLocationState = pathToParams(action.payload.pathname); - const currentParams: RouterLocationState = pathToParams(location ? location.pathname : '/'); - const postActions: Array = []; - - let redirectPath: ?string; - // first event after application loads - if (!location) { - postActions.push({ - type: WALLET.SET_INITIAL_URL, - pathname: action.payload.pathname, - state: requestedParams, - }); - - redirectPath = '/'; - } else { - const isModalOpened: boolean = api.getState().modal.opened; - // there are no devices attached or initialization error occurs - const landingPage: boolean = devices.length < 1 || error !== null; - - // modal is still opened and currentPath is still valid - // example 1 (valid blocking): url changes while passphrase modal opened but device is still connected (we want user to finish this action) - // example 2 (invalid blocking): url changes while passphrase modal opened because device disconnect - if (isModalOpened && action.payload.pathname !== location.pathname && validation(api, currentParams)) { - redirectPath = location.pathname; - console.warn('Modal still opened'); - } else if (landingPage) { - // keep route on landing page - if (action.payload.pathname !== '/' && action.payload.pathname !== '/bridge') { - redirectPath = '/'; - } - } else { - // PATH VALIDATION - // redirect from root view - if (action.payload.pathname === '/' || !validation(api, requestedParams)) { - // TODO redirect to first device - redirectPath = '/device/x'; - } else if (requestedParams.device) { - if (requestedParams.network !== currentParams.network) { - postActions.push({ - type: CONNECT.COIN_CHANGED, - payload: { - network: requestedParams.network, - }, - }); - } - } - } - } - - if (redirectPath) { - console.warn('Redirecting...', redirectPath); - // override action to keep routerReducer sync - const url: string = redirectPath; - action.payload.state = pathToParams(url); - action.payload.pathname = url; - // change url - // api.dispatch(replace(url)); - } else { - action.payload.state = requestedParams; - } - - // resolve LOCATION_CHANGE action - next(action); - - // resolve post actions - postActions.forEach((a) => { - api.dispatch(a); - }); - - api.dispatch(NotificationActions.clear(currentParams, requestedParams)); + // pass action + next(override); - return action; + if (redirect) { + // replace invalid url + api.dispatch(replace(validUrl)); } - // Pass all actions through by default - return next(action); + return override; }; export default RouterService; \ No newline at end of file diff --git a/src/services/TrezorConnectService.js b/src/services/TrezorConnectService.js index 5a4255c2..c14f0a24 100644 --- a/src/services/TrezorConnectService.js +++ b/src/services/TrezorConnectService.js @@ -1,12 +1,12 @@ /* @flow */ -import { push } from 'react-router-redux'; -import TrezorConnect, { - TRANSPORT, DEVICE_EVENT, UI_EVENT, UI, DEVICE, BLOCKCHAIN +import { + TRANSPORT, DEVICE, BLOCKCHAIN, } from 'trezor-connect'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; import * as DiscoveryActions from 'actions/DiscoveryActions'; import * as BlockchainActions from 'actions/BlockchainActions'; +import * as RouterActions from 'actions/RouterActions'; import * as ModalActions from 'actions/ModalActions'; import * as STORAGE from 'actions/constants/localStorage'; import * as CONNECT from 'actions/constants/TrezorConnect'; @@ -31,7 +31,7 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa api.dispatch(TrezorConnectActions.init()); } else if (action.type === TRANSPORT.ERROR) { // TODO: check if modal is open - // api.dispatch( push('/') ); + // api.dispatch( RouterActions.gotoLandingPage() ); } else if (action.type === TRANSPORT.START) { api.dispatch(BlockchainActions.init()); } else if (action.type === BLOCKCHAIN_READY) { @@ -42,7 +42,7 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa api.dispatch(ModalActions.onRememberRequest(prevModalState)); } else if (action.type === CONNECT.FORGET) { //api.dispatch( TrezorConnectActions.forgetDevice(action.device) ); - api.dispatch(TrezorConnectActions.switchToFirstAvailableDevice()); + api.dispatch(RouterActions.selectFirstAvailableDevice()); } else if (action.type === CONNECT.FORGET_SINGLE) { if (api.getState().devices.length < 1 && action.device.connected) { // prompt disconnect device info in LandingPage @@ -50,9 +50,9 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa type: CONNECT.DISCONNECT_REQUEST, device: action.device, }); - api.dispatch(push('/')); + api.dispatch(RouterActions.gotoLandingPage()); } else { - api.dispatch(TrezorConnectActions.switchToFirstAvailableDevice()); + api.dispatch(RouterActions.selectFirstAvailableDevice()); } } else if (action.type === DEVICE.CONNECT || action.type === DEVICE.CONNECT_UNACQUIRED) { api.dispatch(DiscoveryActions.restore()); @@ -60,7 +60,7 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa } else if (action.type === CONNECT.AUTH_DEVICE) { api.dispatch(DiscoveryActions.check()); } else if (action.type === CONNECT.DUPLICATE) { - api.dispatch(TrezorConnectActions.onSelectDevice(action.device)); + api.dispatch(RouterActions.selectDevice(action.device)); } else if (action.type === CONNECT.COIN_CHANGED) { api.dispatch(TrezorConnectActions.coinChanged(action.payload.network)); } else if (action.type === BLOCKCHAIN.BLOCK) { @@ -68,7 +68,7 @@ const TrezorConnectService: Middleware = (api: MiddlewareAPI) => (next: Middlewa } else if (action.type === BLOCKCHAIN.NOTIFICATION) { // api.dispatch(BlockchainActions.onNotification(action.payload)); } else if (action.type === BLOCKCHAIN.ERROR) { - api.dispatch( BlockchainActions.error(action.payload) ); + api.dispatch(BlockchainActions.error(action.payload)); } return action; diff --git a/src/services/WalletService.js b/src/services/WalletService.js index 8b0a69ad..ab9f6b95 100644 --- a/src/services/WalletService.js +++ b/src/services/WalletService.js @@ -1,11 +1,12 @@ /* @flow */ - - import { DEVICE } from 'trezor-connect'; import { LOCATION_CHANGE } from 'react-router-redux'; import * as WALLET from 'actions/constants/wallet'; +import * as CONNECT from 'actions/constants/TrezorConnect'; import * as WalletActions from 'actions/WalletActions'; +import * as RouterActions from 'actions/RouterActions'; +import * as NotificationActions from 'actions/NotificationActions'; import * as LocalStorageActions from 'actions/LocalStorageActions'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; import * as SelectedAccountActions from 'actions/SelectedAccountActions'; @@ -22,40 +23,81 @@ import type { */ const WalletService: Middleware = (api: MiddlewareAPI) => (next: MiddlewareDispatch) => (action: Action): Action => { const prevState = api.getState(); - const locationChange: boolean = action.type === LOCATION_CHANGE; - - // Application live cycle starts here - if (locationChange) { - const { location } = api.getState().router; - if (!location) { - api.dispatch(WalletActions.init()); - // load data from config.json and local storage - api.dispatch(LocalStorageActions.loadData()); - } + + // Application live cycle starts HERE! + // when first LOCATION_CHANGE is called router does not have "location" set yet + if (action.type === LOCATION_CHANGE && !prevState.router.location) { + // initialize wallet + api.dispatch(WalletActions.init()); + // set initial url + // TODO: validate if initial url is potentially correct + api.dispatch({ + type: WALLET.SET_INITIAL_URL, + pathname: action.payload.pathname, + state: {}, + }); + // pass action and break process + return next(action); } // pass action next(action); - if (action.type === DEVICE.CONNECT) { - api.dispatch(WalletActions.clearUnavailableDevicesData(prevState, action.device)); + switch (action.type) { + case WALLET.SET_INITIAL_URL: + api.dispatch(LocalStorageActions.loadData()); + break; + case WALLET.SET_SELECTED_DEVICE: + if (action.device) { + // try to authorize device + api.dispatch(TrezorConnectActions.getSelectedDeviceState()); + } else { + // try select different device + api.dispatch(RouterActions.selectFirstAvailableDevice()); + } + break; + case DEVICE.CONNECT: + api.dispatch(WalletActions.clearUnavailableDevicesData(prevState, action.device)); + break; + default: { + break; + } + } + + // update common values ONLY if application is ready + if (!api.getState().wallet.ready) return action; + + // double verification needed + // Corner case: LOCATION_CHANGE was called but pathname didn't changed (redirection from RouterService) + const prevLocation = prevState.router.location; + const currentLocation = api.getState().router.location; + if (action.type === LOCATION_CHANGE && prevLocation.pathname !== currentLocation.pathname) { + // watch for coin change + if (prevLocation.state.network !== currentLocation.state.network) { + api.dispatch({ + type: CONNECT.COIN_CHANGED, + payload: { + network: currentLocation.state.network, + }, + }); + } + + // watch for account change + if (prevLocation.state.network !== currentLocation.state.network || prevLocation.state.account !== currentLocation.state.account) { + api.dispatch(SelectedAccountActions.dispose()); + } + + // clear notifications + api.dispatch(NotificationActions.clear(prevLocation.state, currentLocation.state)); } + // update common values in WallerReducer api.dispatch(WalletActions.updateSelectedValues(prevState, action)); // update common values in SelectedAccountReducer api.dispatch(SelectedAccountActions.updateSelectedValues(prevState, action)); - // selected device changed - if (action.type === WALLET.SET_SELECTED_DEVICE) { - if (action.device) { - api.dispatch(TrezorConnectActions.getSelectedDeviceState()); - } else { - api.dispatch(TrezorConnectActions.switchToFirstAvailableDevice()); - } - } - return action; }; diff --git a/src/support/routes.js b/src/support/routes.js new file mode 100644 index 00000000..9a921f20 --- /dev/null +++ b/src/support/routes.js @@ -0,0 +1,94 @@ +/* @flow */ + +export type Route = { + +name: string; + +pattern: string; + fields: Array; +} + +export const routes: Array = [ + { + name: 'landing-home', + pattern: '/', + fields: [], + }, + { + name: 'landing-bridge', + pattern: '/bridge', + fields: ['bridge'], + }, + { + name: 'landing-import', + pattern: '/import', + fields: ['import'], + }, + { + name: 'wallet-setting', + pattern: '/settings', + fields: ['settings'], + }, + { + name: 'wallet-acquire', + pattern: '/device/:device/acquire', + fields: ['device', 'acquire'], + }, + { + name: 'wallet-unreadable', + pattern: '/device/:device/unreadable', + fields: ['device', 'unreadable'], + }, + { + name: 'wallet-bootloader', + pattern: '/device/:device/bootloader', + fields: ['device', 'bootloader'], + }, + { + name: 'wallet-initialize', + pattern: '/device/:device/initialize', + fields: ['device', 'initialize'], + }, + { + name: 'wallet-device-settings', + pattern: '/device/:device/settings', + fields: ['device', 'settings'], + }, + { + name: 'wallet-dashboard', + pattern: '/device/:device', + fields: ['device'], + }, + { + name: 'wallet-account-summary', + pattern: '/device/:device/network/:network/account/:account', + fields: ['device', 'network', 'account'], + }, + { + name: 'wallet-account-send', + pattern: '/device/:device/network/:network/account/:account/send', + fields: ['device', 'network', 'account', 'send'], + }, + { + name: 'wallet-account-send-override', + pattern: '/device/:device/network/:network/account/:account/send/override', + fields: ['device', 'network', 'account', 'send'], + }, + { + name: 'wallet-account-receive', + pattern: '/device/:device/network/:network/account/:account/receive', + fields: ['device', 'network', 'account', 'receive'], + }, + { + name: 'wallet-account-signverify', + pattern: '/device/:device/network/:network/account/:account/signverify', + fields: ['device', 'network', 'account', 'signverify'], + }, +]; + +export const getPattern = (name: string): string => { + const entry = routes.find(r => r.name === name); + if (!entry) { + console.error(`Route for ${name} not found`); + return '/'; + } + return entry.pattern; +}; \ No newline at end of file diff --git a/src/views/Wallet/components/LeftNavigation/Container.js b/src/views/Wallet/components/LeftNavigation/Container.js index bef0a9f0..77b7514e 100644 --- a/src/views/Wallet/components/LeftNavigation/Container.js +++ b/src/views/Wallet/components/LeftNavigation/Container.js @@ -5,6 +5,7 @@ import { connect } from 'react-redux'; import { withRouter } from 'react-router-dom'; import * as TrezorConnectActions from 'actions/TrezorConnectActions'; +import * as RouterActions from 'actions/RouterActions'; import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; import type { State, Dispatch } from 'flowtype'; @@ -36,8 +37,8 @@ const mapDispatchToProps: MapDispatchToProps acquireDevice: bindActionCreators(TrezorConnectActions.acquire, dispatch), forgetDevice: bindActionCreators(TrezorConnectActions.forget, dispatch), duplicateDevice: bindActionCreators(TrezorConnectActions.duplicateDevice, dispatch), - gotoDeviceSettings: bindActionCreators(TrezorConnectActions.gotoDeviceSettings, dispatch), - onSelectDevice: bindActionCreators(TrezorConnectActions.onSelectDevice, dispatch), + gotoDeviceSettings: bindActionCreators(RouterActions.gotoDeviceSettings, dispatch), + onSelectDevice: bindActionCreators(RouterActions.selectDevice, dispatch), }); export default withRouter( diff --git a/src/views/Wallet/components/LeftNavigation/components/common.js b/src/views/Wallet/components/LeftNavigation/components/common.js index 785107fb..ee7f1d37 100644 --- a/src/views/Wallet/components/LeftNavigation/components/common.js +++ b/src/views/Wallet/components/LeftNavigation/components/common.js @@ -1,5 +1,6 @@ /* @flow */ import * as TrezorConnectActions from 'actions/TrezorConnectActions'; +import * as RouterActions from 'actions/RouterActions'; import { toggleDeviceDropdown } from 'actions/WalletActions'; import type { State } from 'flowtype'; @@ -22,8 +23,8 @@ export type DispatchProps = { acquireDevice: typeof TrezorConnectActions.acquire, forgetDevice: typeof TrezorConnectActions.forget, duplicateDevice: typeof TrezorConnectActions.duplicateDevice, - gotoDeviceSettings: typeof TrezorConnectActions.gotoDeviceSettings, - onSelectDevice: typeof TrezorConnectActions.onSelectDevice, + gotoDeviceSettings: typeof RouterActions.gotoDeviceSettings, + onSelectDevice: typeof RouterActions.selectDevice, } export type Props = StateProps & DispatchProps; \ No newline at end of file diff --git a/src/views/Wallet/components/SelectedAccount/index.js b/src/views/Wallet/components/SelectedAccount/index.js index 9067b6b1..65e800f9 100644 --- a/src/views/Wallet/components/SelectedAccount/index.js +++ b/src/views/Wallet/components/SelectedAccount/index.js @@ -30,25 +30,27 @@ const SelectedAccount = (props: Props) => { const { account, discovery, - network + network, } = accountState; - if (!network) return; // TODO: this shouldn't happen. change accountState reducer? + // corner case: accountState didn't finish loading state after LOCATION_CHANGE action + if (!network) return (); const blockchain = props.blockchain.find(b => b.name === network.network); if (blockchain && !blockchain.connected) { return ( - { await props.blockchainReconnect(network.network); - } + }, }] - } /> + } + /> ); } diff --git a/src/views/Wallet/views/AccountSend/Container.js b/src/views/Wallet/views/AccountSend/Container.js index 0dee8fbc..14135764 100644 --- a/src/views/Wallet/views/AccountSend/Container.js +++ b/src/views/Wallet/views/AccountSend/Container.js @@ -7,7 +7,6 @@ import { connect } from 'react-redux'; import { reconnect } from 'actions/DiscoveryActions'; import SendFormActions from 'actions/SendFormActions'; -import * as SessionStorageActions from 'actions/SessionStorageActions'; import type { MapStateToProps, MapDispatchToProps } from 'react-redux'; import type { State, Dispatch } from 'flowtype'; import type { StateProps as BaseStateProps, DispatchProps as BaseDispatchProps } from 'views/Wallet/components/SelectedAccount'; @@ -24,7 +23,6 @@ export type StateProps = BaseStateProps & { export type DispatchProps = BaseDispatchProps & { sendFormActions: typeof SendFormActions, - saveSessionStorage: typeof SessionStorageActions.save } export type Props = StateProps & BaseStateProps & DispatchProps & BaseDispatchProps; @@ -43,7 +41,6 @@ const mapStateToProps: MapStateToProps = (state: St const mapDispatchToProps: MapDispatchToProps = (dispatch: Dispatch): DispatchProps => ({ blockchainReconnect: bindActionCreators(reconnect, dispatch), sendFormActions: bindActionCreators(SendFormActions, dispatch), - saveSessionStorage: bindActionCreators(SessionStorageActions.save, dispatch), }); export default connect(mapStateToProps, mapDispatchToProps)(AccountSend); \ No newline at end of file diff --git a/src/views/Wallet/views/AccountSend/index.js b/src/views/Wallet/views/AccountSend/index.js index 024fca6a..c2318e72 100644 --- a/src/views/Wallet/views/AccountSend/index.js +++ b/src/views/Wallet/views/AccountSend/index.js @@ -199,8 +199,6 @@ class AccountSend extends Component { componentWillReceiveProps(newProps: Props) { calculate(this.props, newProps); validation(newProps); - - this.props.saveSessionStorage(); } getAddressInputState(address: string, addressErrors: string, addressWarnings: string) { diff --git a/src/views/index.js b/src/views/index.js index d79e7628..11fa8bed 100644 --- a/src/views/index.js +++ b/src/views/index.js @@ -6,6 +6,7 @@ import { ConnectedRouter } from 'react-router-redux'; // general import ErrorBoundary from 'support/ErrorBoundary'; +import { getPattern } from 'support/routes'; import LandingContainer from 'views/Landing/Container'; // wallet views @@ -29,25 +30,24 @@ const App = () => ( - - - + + + - - - - - - - - - - - - - + + + + + + + + + + + +