Better Offline Detection / Handling (#478)

* Change navigator.onLine to actually pinging the network. Refactor notifications to take Infinity instead of 'infinity'

* Stop polling when forced offline.

* Show spinners if unit display balance is null, show offline text if were actually offline.

* Fix issue with typescript and connected union-prop components.

* Only ping the node when navigator.onLine changes.
This commit is contained in:
William O'Beirne 2017-11-28 19:17:26 -05:00 committed by Daniel Ternyak
parent 1eb687c670
commit 1221a73a46
11 changed files with 158 additions and 39 deletions

View File

@ -6,7 +6,7 @@ export type TShowNotification = typeof showNotification;
export function showNotification( export function showNotification(
level: types.NOTIFICATION_LEVEL = 'info', level: types.NOTIFICATION_LEVEL = 'info',
msg: ReactElement<any> | string, msg: ReactElement<any> | string,
duration?: number | types.INFINITY duration?: number
): types.ShowNotificationAction { ): types.ShowNotificationAction {
return { return {
type: TypeKeys.SHOW_NOTIFICATION, type: TypeKeys.SHOW_NOTIFICATION,

View File

@ -2,13 +2,12 @@ import { ReactElement } from 'react';
import { TypeKeys } from './constants'; import { TypeKeys } from './constants';
/*** Shared types ***/ /*** Shared types ***/
export type NOTIFICATION_LEVEL = 'danger' | 'warning' | 'success' | 'info'; export type NOTIFICATION_LEVEL = 'danger' | 'warning' | 'success' | 'info';
export type INFINITY = 'infinity';
export interface Notification { export interface Notification {
level: NOTIFICATION_LEVEL; level: NOTIFICATION_LEVEL;
msg: ReactElement<any> | string; msg: ReactElement<any> | string;
id: number; id: number;
duration?: number | INFINITY; duration?: number;
} }
/*** Close notification ***/ /*** Close notification ***/

View File

@ -1,4 +1,5 @@
import React from 'react'; import React from 'react';
import { connect } from 'react-redux';
import { import {
fromTokenBase, fromTokenBase,
getDecimal, getDecimal,
@ -7,6 +8,9 @@ import {
TokenValue TokenValue
} from 'libs/units'; } from 'libs/units';
import { formatNumber as format } from 'utils/formatters'; import { formatNumber as format } from 'utils/formatters';
import Spinner from 'components/ui/Spinner';
import { getOffline } from 'selectors/config';
import { AppState } from 'reducers';
interface Props { interface Props {
/** /**
@ -41,33 +45,63 @@ const isEthereumUnit = (param: EthProps | TokenProps): param is EthProps =>
const UnitDisplay: React.SFC<EthProps | TokenProps> = params => { const UnitDisplay: React.SFC<EthProps | TokenProps> = params => {
const { value, symbol, displayShortBalance } = params; const { value, symbol, displayShortBalance } = params;
let element;
if (!value) { if (!value) {
return <span>Balance isn't available offline</span>; element = <Spinner size="x1" />;
}
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 { } 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 = (
<span>
{formattedValue}
{symbol ? ` ${symbol}` : ''}
</span>
);
} }
return ( return <ConnectedOfflineDisplay>{element}</ConnectedOfflineDisplay>;
<span>
{formattedValue}
{symbol ? ` ${symbol}` : ''}
</span>
);
}; };
export default UnitDisplay; 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<string>;
}
class OfflineDisplay extends React.Component<OfflineProps> {
public render() {
if (this.props.offline) {
return <span>Balance isn't available offline</span>;
} else {
return this.props.children;
}
}
}
function mapStateToOfflineProps(state: AppState) {
return {
offline: getOffline(state)
};
}
const ConnectedOfflineDisplay = connect(mapStateToOfflineProps)(OfflineDisplay);

View File

@ -7,6 +7,7 @@ export interface TxObj {
data: string; data: string;
} }
export interface INode { export interface INode {
ping(): Promise<boolean>;
getBalance(address: string): Promise<Wei>; getBalance(address: string): Promise<Wei>;
getTokenBalance(address: string, token: Token): Promise<TokenValue>; getTokenBalance(address: string, token: Token): Promise<TokenValue>;
getTokenBalances(address: string, tokens: Token[]): Promise<TokenValue[]>; getTokenBalances(address: string, tokens: Token[]): Promise<TokenValue[]>;

View File

@ -23,6 +23,13 @@ export default class RpcNode implements INode {
this.requests = new RPCRequests(); this.requests = new RPCRequests();
} }
public ping(): Promise<boolean> {
return this.client
.call(this.requests.getNetVersion())
.then(() => true)
.catch(() => false);
}
public sendCallRequest(txObj: TxObj): Promise<string> { public sendCallRequest(txObj: TxObj): Promise<string> {
return this.client.call(this.requests.ethCall(txObj)).then(r => { return this.client.call(this.requests.ethCall(txObj)).then(r => {
if (r.error) { if (r.error) {

View File

@ -7,11 +7,15 @@ import {
GetTokenBalanceRequest, GetTokenBalanceRequest,
GetTransactionCountRequest, GetTransactionCountRequest,
SendRawTxRequest, SendRawTxRequest,
GetCurrentBlockRequest, GetCurrentBlockRequest
} from './types'; } from './types';
import { hexEncodeData } from './utils'; import { hexEncodeData } from './utils';
import { TxObj } from '../INode'; import { TxObj } from '../INode';
export default class RPCRequests { export default class RPCRequests {
public getNetVersion() {
return { method: 'net_version' };
}
public sendRawTx(signedTx: string): SendRawTxRequest | any { public sendRawTx(signedTx: string): SendRawTxRequest | any {
return { return {
method: 'eth_sendRawTransaction', method: 'eth_sendRawTransaction',
@ -67,7 +71,7 @@ export default class RPCRequests {
public getCurrentBlock(): GetCurrentBlockRequest | any { public getCurrentBlock(): GetCurrentBlockRequest | any {
return { return {
method: 'eth_blockNumber', method: 'eth_blockNumber'
}; };
} }
} }

View File

@ -11,6 +11,10 @@ export default class Web3Node implements INode {
this.web3 = web3; this.web3 = web3;
} }
public ping(): Promise<boolean> {
return Promise.resolve(true);
}
public sendCallRequest(txObj: TxObj): Promise<string> { public sendCallRequest(txObj: TxObj): Promise<string> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
this.web3.eth.call(txObj, 'pending', (err, res) => { this.web3.eth.call(txObj, 'pending', (err, res) => {

View File

@ -16,7 +16,13 @@ import {
getCustomNodeConfigFromId, getCustomNodeConfigFromId,
makeNodeConfigFromCustomConfig makeNodeConfigFromCustomConfig
} from 'utils/node'; } from 'utils/node';
import { getNode, getNodeConfig, getCustomNodeConfigs } from 'selectors/config'; import {
getNode,
getNodeConfig,
getCustomNodeConfigs,
getOffline,
getForceOffline
} from 'selectors/config';
import { AppState } from 'reducers'; import { AppState } from 'reducers';
import { TypeKeys } from 'actions/config/constants'; import { TypeKeys } from 'actions/config/constants';
import { import {
@ -38,15 +44,58 @@ import {
export const getConfig = (state: AppState): ConfigState => state.config; export const getConfig = (state: AppState): ConfigState => state.config;
let hasCheckedOnline = false;
export function* pollOfflineStatus(): SagaIterator { export function* pollOfflineStatus(): SagaIterator {
while (true) { while (true) {
const offline = !navigator.onLine; const node = yield select(getNodeConfig);
const config = yield select(getConfig); const isOffline = yield select(getOffline);
const offlineState = config.offline; const isForcedOffline = yield select(getForceOffline);
if (offline !== offlineState) {
yield put(toggleOfflineConfig()); // 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',
`Youve 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); 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 // @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. // data to reload. Also the use of timeout to avoid using additional actions for now.
function* reload(): SagaIterator { function* reload(): SagaIterator {
@ -164,6 +222,7 @@ export default function* configSaga(): SagaIterator {
TypeKeys.CONFIG_POLL_OFFLINE_STATUS, TypeKeys.CONFIG_POLL_OFFLINE_STATUS,
handlePollOfflineStatus handlePollOfflineStatus
); );
yield takeEvery(TypeKeys.CONFIG_FORCE_OFFLINE, handleTogglePollOfflineStatus);
yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent); yield takeEvery(TypeKeys.CONFIG_NODE_CHANGE_INTENT, handleNodeChangeIntent);
yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload); yield takeEvery(TypeKeys.CONFIG_LANGUAGE_CHANGE, reload);
yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode); yield takeEvery(TypeKeys.CONFIG_ADD_CUSTOM_NODE, switchToNewNode);

View File

@ -1,11 +1,14 @@
import { closeNotification, ShowNotificationAction } from 'actions/notifications'; import {
closeNotification,
ShowNotificationAction
} from 'actions/notifications';
import { delay, SagaIterator } from 'redux-saga'; import { delay, SagaIterator } from 'redux-saga';
import { call, put, takeEvery } from 'redux-saga/effects'; import { call, put, takeEvery } from 'redux-saga/effects';
function* handleNotification(action: ShowNotificationAction): SagaIterator { function* handleNotification(action: ShowNotificationAction): SagaIterator {
const { duration } = action.payload; const { duration } = action.payload;
// show forever // show forever
if (duration === 0 || duration === 'infinity') { if (duration === 0 || duration === Infinity) {
return; return;
} }

View File

@ -146,7 +146,7 @@ export function* bityTimeRemaining(): SagaIterator {
if (!hasShownNotification) { if (!hasShownNotification) {
hasShownNotification = true; hasShownNotification = true;
yield put( yield put(
showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity') showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity)
); );
} }
break; break;
@ -156,7 +156,7 @@ export function* bityTimeRemaining(): SagaIterator {
if (!hasShownNotification) { if (!hasShownNotification) {
hasShownNotification = true; hasShownNotification = true;
yield put( yield put(
showNotification('danger', BITY_TIMEOUT_MESSAGE, 'infinity') showNotification('danger', BITY_TIMEOUT_MESSAGE, Infinity)
); );
} }
break; break;
@ -164,7 +164,7 @@ export function* bityTimeRemaining(): SagaIterator {
if (!hasShownNotification) { if (!hasShownNotification) {
hasShownNotification = true; hasShownNotification = true;
yield put( yield put(
showNotification('warning', BITY_TIMEOUT_MESSAGE, 'infinity') showNotification('warning', BITY_TIMEOUT_MESSAGE, Infinity)
); );
} }
break; break;

View File

@ -39,3 +39,11 @@ export function getLanguageSelection(state: AppState): string {
export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] { export function getCustomNodeConfigs(state: AppState): CustomNodeConfig[] {
return state.config.customNodes; return state.config.customNodes;
} }
export function getOffline(state: AppState): boolean {
return state.config.offline;
}
export function getForceOffline(state: AppState): boolean {
return state.config.forceOffline;
}