diff --git a/common/actions/notifications/actionCreators.ts b/common/actions/notifications/actionCreators.ts index 75c0a735..bfda0fa3 100644 --- a/common/actions/notifications/actionCreators.ts +++ b/common/actions/notifications/actionCreators.ts @@ -6,7 +6,7 @@ export type TShowNotification = typeof showNotification; export function showNotification( level: types.NOTIFICATION_LEVEL = 'info', msg: ReactElement | string, - duration?: number | types.INFINITY + duration?: number ): types.ShowNotificationAction { return { type: TypeKeys.SHOW_NOTIFICATION, diff --git a/common/actions/notifications/actionTypes.ts b/common/actions/notifications/actionTypes.ts index 16de73a2..f6b70117 100644 --- a/common/actions/notifications/actionTypes.ts +++ b/common/actions/notifications/actionTypes.ts @@ -2,13 +2,12 @@ import { ReactElement } from 'react'; import { TypeKeys } from './constants'; /*** Shared types ***/ export type NOTIFICATION_LEVEL = 'danger' | 'warning' | 'success' | 'info'; -export type INFINITY = 'infinity'; export interface Notification { level: NOTIFICATION_LEVEL; msg: ReactElement | string; id: number; - duration?: number | INFINITY; + duration?: number; } /*** Close notification ***/ diff --git a/common/components/ui/UnitDisplay.tsx b/common/components/ui/UnitDisplay.tsx index 1bce8bb0..8ef88a50 100644 --- a/common/components/ui/UnitDisplay.tsx +++ b/common/components/ui/UnitDisplay.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { connect } from 'react-redux'; import { fromTokenBase, getDecimal, @@ -7,6 +8,9 @@ import { TokenValue } from 'libs/units'; import { formatNumber as format } from 'utils/formatters'; +import Spinner from 'components/ui/Spinner'; +import { getOffline } from 'selectors/config'; +import { AppState } from 'reducers'; interface Props { /** @@ -41,33 +45,63 @@ const isEthereumUnit = (param: EthProps | TokenProps): param is EthProps => const UnitDisplay: React.SFC = params => { const { value, symbol, displayShortBalance } = params; + let element; if (!value) { - return Balance isn't available offline; - } - - const convertedValue = isEthereumUnit(params) - ? fromTokenBase(value, getDecimal(params.unit)) - : fromTokenBase(value, params.decimal); - - let formattedValue; - - if (displayShortBalance) { - const digits = - typeof displayShortBalance === 'number' && displayShortBalance; - formattedValue = digits - ? format(convertedValue, digits) - : format(convertedValue); + element = ; } else { - formattedValue = convertedValue; + const convertedValue = isEthereumUnit(params) + ? fromTokenBase(value, getDecimal(params.unit)) + : fromTokenBase(value, params.decimal); + + let formattedValue; + + if (displayShortBalance) { + const digits = + typeof displayShortBalance === 'number' && displayShortBalance; + formattedValue = digits + ? format(convertedValue, digits) + : format(convertedValue); + } else { + formattedValue = convertedValue; + } + + element = ( + + {formattedValue} + {symbol ? ` ${symbol}` : ''} + + ); } - return ( - - {formattedValue} - {symbol ? ` ${symbol}` : ''} - - ); + return {element}; }; export default UnitDisplay; + +/** + * @description Helper component for displaying alternate text when offline. + * Circumvents typescript issue with union props on connected components. + */ +interface OfflineProps { + offline: AppState['config']['offline']; + children: React.ReactElement; +} + +class OfflineDisplay extends React.Component { + public render() { + if (this.props.offline) { + return Balance isn't available offline; + } else { + return this.props.children; + } + } +} + +function mapStateToOfflineProps(state: AppState) { + return { + offline: getOffline(state) + }; +} + +const ConnectedOfflineDisplay = connect(mapStateToOfflineProps)(OfflineDisplay); diff --git a/common/libs/nodes/INode.ts b/common/libs/nodes/INode.ts index 669728ea..653c2c9a 100644 --- a/common/libs/nodes/INode.ts +++ b/common/libs/nodes/INode.ts @@ -7,6 +7,7 @@ export interface TxObj { data: string; } export interface INode { + ping(): Promise; getBalance(address: string): Promise; getTokenBalance(address: string, token: Token): Promise; getTokenBalances(address: string, tokens: Token[]): Promise; diff --git a/common/libs/nodes/rpc/index.ts b/common/libs/nodes/rpc/index.ts index 26229d56..af5acd82 100644 --- a/common/libs/nodes/rpc/index.ts +++ b/common/libs/nodes/rpc/index.ts @@ -23,6 +23,13 @@ export default class RpcNode implements INode { this.requests = new RPCRequests(); } + public ping(): Promise { + return this.client + .call(this.requests.getNetVersion()) + .then(() => true) + .catch(() => false); + } + public sendCallRequest(txObj: TxObj): Promise { return this.client.call(this.requests.ethCall(txObj)).then(r => { if (r.error) { diff --git a/common/libs/nodes/rpc/requests.ts b/common/libs/nodes/rpc/requests.ts index 848459c7..ba11c1fc 100644 --- a/common/libs/nodes/rpc/requests.ts +++ b/common/libs/nodes/rpc/requests.ts @@ -7,11 +7,15 @@ import { GetTokenBalanceRequest, GetTransactionCountRequest, SendRawTxRequest, - GetCurrentBlockRequest, + GetCurrentBlockRequest } from './types'; import { hexEncodeData } from './utils'; import { TxObj } from '../INode'; export default class RPCRequests { + public getNetVersion() { + return { method: 'net_version' }; + } + public sendRawTx(signedTx: string): SendRawTxRequest | any { return { method: 'eth_sendRawTransaction', @@ -67,7 +71,7 @@ export default class RPCRequests { public getCurrentBlock(): GetCurrentBlockRequest | any { return { - method: 'eth_blockNumber', + method: 'eth_blockNumber' }; } } diff --git a/common/libs/nodes/web3/index.ts b/common/libs/nodes/web3/index.ts index 925c507c..47a4de75 100644 --- a/common/libs/nodes/web3/index.ts +++ b/common/libs/nodes/web3/index.ts @@ -11,6 +11,10 @@ export default class Web3Node implements INode { this.web3 = web3; } + public ping(): Promise { + return Promise.resolve(true); + } + public sendCallRequest(txObj: TxObj): Promise { return new Promise((resolve, reject) => { this.web3.eth.call(txObj, 'pending', (err, res) => { diff --git a/common/sagas/config.ts b/common/sagas/config.ts index 9693b80b..b97031a4 100644 --- a/common/sagas/config.ts +++ b/common/sagas/config.ts @@ -16,7 +16,13 @@ import { getCustomNodeConfigFromId, makeNodeConfigFromCustomConfig } from 'utils/node'; -import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config'; +import { + getNode, + getNodeConfig, + getCustomNodeConfigs, + getOffline, + getForceOffline +} from 'selectors/config'; import { AppState } from 'reducers'; import { TypeKeys } from 'actions/config/constants'; import { @@ -38,15 +44,58 @@ import { export const getConfig = (state: AppState): ConfigState => state.config; +let hasCheckedOnline = false; export function* pollOfflineStatus(): SagaIterator { while (true) { - const offline = !navigator.onLine; - const config = yield select(getConfig); - const offlineState = config.offline; - if (offline !== offlineState) { - yield put(toggleOfflineConfig()); + const node = yield select(getNodeConfig); + const isOffline = yield select(getOffline); + const isForcedOffline = yield select(getForceOffline); + + // If they're forcing themselves offline, exit the loop. It will be + // kicked off again if they toggle it in handleTogglePollOfflineStatus. + if (isForcedOffline) { + return; + } + + // If our offline state disagrees with the browser, run a check + // Don't check if the user is in another tab or window + const shouldPing = !hasCheckedOnline || navigator.onLine === isOffline; + if (shouldPing && !document.hidden) { + hasCheckedOnline = true; + const { pingSucceeded } = yield race({ + pingSucceeded: call(node.lib.ping.bind(node.lib)), + timeout: call(delay, 5000) + }); + + if (pingSucceeded && isOffline) { + // If we were able to ping but redux says we're offline, mark online + yield put( + showNotification( + 'success', + 'Your connection to the network has been restored!', + 3000 + ) + ); + yield put(toggleOfflineConfig()); + } else if (!pingSucceeded && !isOffline) { + // If we were unable to ping but redux says we're online, mark offline + yield put( + showNotification( + 'danger', + `You’ve lost your connection to the network, check your internet + connection or try changing networks from the dropdown at the + top right of the page.`, + Infinity + ) + ); + yield put(toggleOfflineConfig()); + } else { + // If neither case was true, try again in 5s + yield call(delay, 5000); + } + } else { + yield call(delay, 1000); } - yield call(delay, 250); } } @@ -57,6 +106,15 @@ function* handlePollOfflineStatus(): SagaIterator { yield cancel(pollOfflineStatusTask); } +function* handleTogglePollOfflineStatus(): SagaIterator { + const isForcedOffline = yield select(getForceOffline); + if (isForcedOffline) { + yield fork(handlePollOfflineStatus); + } else { + yield call(handlePollOfflineStatus); + } +} + // @HACK For now we reload the app when doing a language swap to force non-connected // data to reload. Also the use of timeout to avoid using additional actions for now. function* reload(): SagaIterator { @@ -164,6 +222,7 @@ export default function* configSaga(): SagaIterator { TypeKeys.CONFIG_POLL_OFFLINE_STATUS, handlePollOfflineStatus ); + yield takeEvery(TypeKeys.CONFIG_FORCE_OFFLINE, handleTogglePollOfflineStatus); yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent); yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode); diff --git a/common/sagas/notifications.ts b/common/sagas/notifications.ts index d90bcd7b..c21521da 100644 --- a/common/sagas/notifications.ts +++ b/common/sagas/notifications.ts @@ -1,11 +1,14 @@ -import { closeNotification, ShowNotificationAction } from 'actions/notifications'; +import { + closeNotification, + ShowNotificationAction +} from 'actions/notifications'; import { delay, SagaIterator } from 'redux-saga'; import { call, put, takeEvery } from 'redux-saga/effects'; function* handleNotification(action: ShowNotificationAction): SagaIterator { const { duration } = action.payload; // show forever - if (duration === 0 || duration === 'infinity') { + if (duration === 0 || duration === Infinity) { return; } diff --git a/common/sagas/swap/orders.ts b/common/sagas/swap/orders.ts index 334d6c0b..c78754c1 100644 --- a/common/sagas/swap/orders.ts +++ b/common/sagas/swap/orders.ts @@ -146,7 +146,7 @@ export function* bityTimeRemaining(): SagaIterator { if (!hasShownNotification) { hasShownNotification = true; yield put( - showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity') + showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity) ); } break; @@ -156,7 +156,7 @@ export function* bityTimeRemaining(): SagaIterator { if (!hasShownNotification) { hasShownNotification = true; yield put( - showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity') + showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity) ); } break; @@ -164,7 +164,7 @@ export function* bityTimeRemaining(): SagaIterator { if (!hasShownNotification) { hasShownNotification = true; yield put( - showNotification('warning', BITY_TIMEOUT_MESSAGE, 'infinity') + showNotification('warning', BITY_TIMEOUT_MESSAGE, Infinity) ); } break; diff --git a/common/selectors/config.ts b/common/selectors/config.ts index 1c4e8fab..55ee44ff 100644 --- a/common/selectors/config.ts +++ b/common/selectors/config.ts @@ -39,3 +39,11 @@ export function getLanguageSelection(state: AppState): string { export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] { return state.config.customNodes; } + +export function getOffline(state: AppState): boolean { + return state.config.offline; +} + +export function getForceOffline(state: AppState): boolean { + return state.config.forceOffline; +}