diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx
index 4099a9baa..37cc777bc 100644
--- a/ts/components/LeftPane.stories.tsx
+++ b/ts/components/LeftPane.stories.tsx
@@ -201,6 +201,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
i18n={i18n}
socketStatus={SocketStatus.CLOSED}
isOnline={false}
+ isOutage={false}
manualReconnect={action('manualReconnect')}
{...overrideProps.dialogNetworkStatus}
{...props}
diff --git a/ts/components/LeftPaneDialog.tsx b/ts/components/LeftPaneDialog.tsx
index bfc12cffe..b5abc0fd5 100644
--- a/ts/components/LeftPaneDialog.tsx
+++ b/ts/components/LeftPaneDialog.tsx
@@ -12,7 +12,7 @@ const TOOLTIP_CLASS_NAME = `${BASE_CLASS_NAME}__tooltip`;
export type PropsType = {
type?: 'warning' | 'error';
- icon?: 'update' | 'relink' | 'network' | 'warning' | ReactChild;
+ icon?: 'update' | 'relink' | 'network' | 'warning' | 'error' | ReactChild;
title?: string;
subtitle?: string;
children?: ReactNode;
diff --git a/ts/services/networkObserver.ts b/ts/services/networkObserver.ts
index 8af7c1aa6..0871d444b 100644
--- a/ts/services/networkObserver.ts
+++ b/ts/services/networkObserver.ts
@@ -8,10 +8,19 @@ import type {
import { getSocketStatus } from '../shims/socketStatus';
import * as log from '../logging/log';
import { SECOND } from '../util/durations';
+import { electronLookup } from '../util/dns';
+import { drop } from '../util/drop';
+import { SocketStatus } from '../types/SocketStatus';
+
+// DNS TTL
+const OUTAGE_CHECK_INTERVAL = 60 * SECOND;
+const OUTAGE_HEALTY_ADDR = '127.0.0.1';
+const OUTAGE_NO_SERVICE_ADDR = '127.0.0.2';
type NetworkActions = {
checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType;
closeConnectingGracePeriod: () => NetworkActionType;
+ setOutage: (isOutage: boolean) => NetworkActionType;
};
export function initializeNetworkObserver(
@@ -26,9 +35,62 @@ export function initializeNetworkObserver(
isOnline: navigator.onLine,
socketStatus,
});
+
+ if (socketStatus === SocketStatus.OPEN) {
+ onOutageEnd();
+ }
+ };
+
+ let outageTimer: NodeJS.Timeout | undefined;
+
+ const checkOutage = async (): Promise
=> {
+ electronLookup('uptime.signal.org', { all: false }, (error, address) => {
+ if (error) {
+ log.error('networkObserver: outage check failure', error);
+ return;
+ }
+
+ if (address === OUTAGE_HEALTY_ADDR) {
+ log.info(
+ 'networkObserver: got healthy response from uptime.signal.org'
+ );
+ onOutageEnd();
+ } else if (address === OUTAGE_NO_SERVICE_ADDR) {
+ log.warn('networkObserver: service is down');
+ networkActions.setOutage(true);
+ } else {
+ log.error(
+ 'networkObserver: unexpected DNS response for uptime.signal.org'
+ );
+ }
+ });
+ };
+
+ const onPotentialOutage = (): void => {
+ if (outageTimer != null) {
+ return;
+ }
+
+ log.warn('networkObserver: initiating outage check');
+
+ outageTimer = setInterval(() => drop(checkOutage()), OUTAGE_CHECK_INTERVAL);
+ drop(checkOutage());
+ };
+
+ const onOutageEnd = (): void => {
+ if (outageTimer == null) {
+ return;
+ }
+
+ log.warn('networkObserver: clearing outage check');
+ clearInterval(outageTimer);
+ outageTimer = undefined;
+
+ networkActions.setOutage(false);
};
window.Whisper.events.on('socketStatusChange', refresh);
+ window.Whisper.events.on('socketConnectError', onPotentialOutage);
window.addEventListener('online', refresh);
window.addEventListener('offline', refresh);
diff --git a/ts/state/ducks/network.ts b/ts/state/ducks/network.ts
index 2eab8ef99..f98ee5077 100644
--- a/ts/state/ducks/network.ts
+++ b/ts/state/ducks/network.ts
@@ -10,6 +10,7 @@ import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnece
export type NetworkStateType = ReadonlyDeep<{
isOnline: boolean;
+ isOutage: boolean;
socketStatus: SocketStatus;
withinConnectingGracePeriod: boolean;
challengeStatus: 'required' | 'pending' | 'idle';
@@ -21,6 +22,7 @@ const CHECK_NETWORK_STATUS = 'network/CHECK_NETWORK_STATUS';
const CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
const RELINK_DEVICE = 'network/RELINK_DEVICE';
const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS';
+const SET_OUTAGE = 'network/SET_OUTAGE';
export type CheckNetworkStatusPayloadType = ReadonlyDeep<{
isOnline: boolean;
@@ -47,11 +49,19 @@ type SetChallengeStatusActionType = ReadonlyDeep<{
};
}>;
+type SetOutageActionType = ReadonlyDeep<{
+ type: 'network/SET_OUTAGE';
+ payload: {
+ isOutage: boolean;
+ };
+}>;
+
export type NetworkActionType = ReadonlyDeep<
| CheckNetworkStatusAction
| CloseConnectingGracePeriodActionType
| RelinkDeviceActionType
| SetChallengeStatusActionType
+ | SetOutageActionType
>;
// Action Creators
@@ -88,11 +98,19 @@ function setChallengeStatus(
};
}
+function setOutage(isOutage: boolean): SetOutageActionType {
+ return {
+ type: SET_OUTAGE,
+ payload: { isOutage },
+ };
+}
+
export const actions = {
checkNetworkStatus,
closeConnectingGracePeriod,
relinkDevice,
setChallengeStatus,
+ setOutage,
};
// Reducer
@@ -100,6 +118,7 @@ export const actions = {
export function getEmptyState(): NetworkStateType {
return {
isOnline: navigator.onLine,
+ isOutage: false,
socketStatus: SocketStatus.OPEN,
withinConnectingGracePeriod: true,
challengeStatus: 'idle',
@@ -135,5 +154,16 @@ export function reducer(
};
}
+ if (action.type === SET_OUTAGE) {
+ const { isOutage } = action.payload;
+
+ // This action is dispatched frequently when offline.
+ // We avoid allocating a new object if nothing has changed to
+ // avoid an unnecessary re-render.
+ return assignWithNoUnnecessaryAllocation(state, {
+ isOutage,
+ });
+ }
+
return state;
}
diff --git a/ts/state/selectors/network.ts b/ts/state/selectors/network.ts
index 43698e9f6..f1f05cd23 100644
--- a/ts/state/selectors/network.ts
+++ b/ts/state/selectors/network.ts
@@ -14,11 +14,17 @@ export const hasNetworkDialog = createSelector(
getNetwork,
isDone,
(
- { isOnline, socketStatus, withinConnectingGracePeriod }: NetworkStateType,
+ {
+ isOnline,
+ isOutage,
+ socketStatus,
+ withinConnectingGracePeriod,
+ }: NetworkStateType,
isRegistrationDone: boolean
): boolean =>
isRegistrationDone &&
(!isOnline ||
+ isOutage ||
(socketStatus === SocketStatus.CONNECTING &&
!withinConnectingGracePeriod) ||
socketStatus === SocketStatus.CLOSED ||
diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts
index 67951dd9e..2d99cda83 100644
--- a/ts/textsecure/SocketManager.ts
+++ b/ts/textsecure/SocketManager.ts
@@ -219,6 +219,10 @@ export class SocketManager extends EventListener {
// No reconnect attempt should be made
return;
}
+
+ if (code === -1) {
+ this.emit('connectError');
+ }
}
void reconnect();
@@ -706,6 +710,7 @@ export class SocketManager extends EventListener {
): this;
public override on(type: 'statusChange', callback: () => void): this;
public override on(type: 'deviceConflict', callback: () => void): this;
+ public override on(type: 'connectError', callback: () => void): this;
public override on(
type: string | symbol,
@@ -718,6 +723,7 @@ export class SocketManager extends EventListener {
public override emit(type: 'authError', error: HTTPError): boolean;
public override emit(type: 'statusChange'): boolean;
public override emit(type: 'deviceConflict'): boolean;
+ public override emit(type: 'connectError'): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
public override emit(type: string | symbol, ...args: Array): boolean {
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index 73a069c4f..09729a1ff 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -1325,6 +1325,10 @@ export function initialize({
window.Whisper.events.trigger('unlinkAndDisconnect');
});
+ socketManager.on('connectError', () => {
+ window.Whisper.events.trigger('socketConnectError');
+ });
+
socketManager.on('deviceConflict', () => {
window.Whisper.events.trigger('unlinkAndDisconnect');
});