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(
level: types.NOTIFICATION_LEVEL = 'info',
msg: ReactElement<any> | string,
duration?: number | types.INFINITY
duration?: number
): types.ShowNotificationAction {
return {
type: TypeKeys.SHOW_NOTIFICATION,

View File

@ -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<any> | string;
id: number;
duration?: number | INFINITY;
duration?: number;
}
/*** Close notification ***/

View File

@ -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,11 +45,11 @@ const isEthereumUnit = (param: EthProps | TokenProps): param is EthProps =>
const UnitDisplay: React.SFC<EthProps | TokenProps> = params => {
const { value, symbol, displayShortBalance } = params;
let element;
if (!value) {
return <span>Balance isn't available offline</span>;
}
element = <Spinner size="x1" />;
} else {
const convertedValue = isEthereumUnit(params)
? fromTokenBase(value, getDecimal(params.unit))
: fromTokenBase(value, params.decimal);
@ -62,12 +66,42 @@ const UnitDisplay: React.SFC<EthProps | TokenProps> = params => {
formattedValue = convertedValue;
}
return (
element = (
<span>
{formattedValue}
{symbol ? ` ${symbol}` : ''}
</span>
);
}
return <ConnectedOfflineDisplay>{element}</ConnectedOfflineDisplay>;
};
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;
}
export interface INode {
ping(): Promise<boolean>;
getBalance(address: string): Promise<Wei>;
getTokenBalance(address: string, token: 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();
}
public ping(): Promise<boolean> {
return this.client
.call(this.requests.getNetVersion())
.then(() => true)
.catch(() => false);
}
public sendCallRequest(txObj: TxObj): Promise<string> {
return this.client.call(this.requests.ethCall(txObj)).then(r => {
if (r.error) {

View File

@ -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'
};
}
}

View File

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

View File

@ -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',
`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);
}
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);

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 { 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;
}

View File

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

View File

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