Introduce outage network status

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Fedor Indutny 2024-03-12 12:52:02 -07:00 committed by GitHub
parent 1ca4ee555f
commit 1823f7eca9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 164 additions and 3 deletions

View file

@ -6182,6 +6182,10 @@
"messageformat": "Update Downloaded",
"description": "The title of update dialog when update download is completed."
},
"icu:DialogNetworkStatus__outage": {
"messageformat": "Signal is experiencing technical difficulties. We are working hard to restore service as quickly as possible.",
"description": "The title of outage dialog during service outage."
},
"icu:InstallScreenUpdateDialog--unsupported-os__title": {
"messageformat": "Update Required",
"description": "The title of update dialog on install screen when user OS is unsupported"

View file

@ -107,6 +107,11 @@
-webkit-mask: url('../images/icons/v3/error/error-triangle.svg') no-repeat
center;
}
&--error {
-webkit-mask: url('../images/icons/v3/error/error-circle.svg') no-repeat
center;
}
}
&__action-text {

View file

@ -20,6 +20,7 @@ const defaultProps = {
hasNetworkDialog: true,
i18n,
isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING,
manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false,
@ -54,6 +55,7 @@ KnobsPlayground.args = {
containerWidthBreakpoint: WidthBreakpoint.Wide,
hasNetworkDialog: true,
isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING,
};
@ -105,6 +107,19 @@ export function OfflineWide(): JSX.Element {
);
}
export function OutageWide(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Wide}>
<DialogNetworkStatus
{...defaultProps}
containerWidthBreakpoint={WidthBreakpoint.Wide}
isOnline={false}
isOutage
/>
</FakeLeftPaneContainer>
);
}
export function ConnectingNarrow(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
@ -152,3 +167,16 @@ export function OfflineNarrow(): JSX.Element {
</FakeLeftPaneContainer>
);
}
export function OutageNarrow(): JSX.Element {
return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
<DialogNetworkStatus
{...defaultProps}
containerWidthBreakpoint={WidthBreakpoint.Narrow}
isOnline={false}
isOutage
/>
</FakeLeftPaneContainer>
);
}

View file

@ -13,7 +13,10 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
const FIVE_SECONDS = 5 * 1000;
export type PropsType = Pick<NetworkStateType, 'isOnline' | 'socketStatus'> & {
export type PropsType = Pick<
NetworkStateType,
'isOnline' | 'isOutage' | 'socketStatus'
> & {
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
manualReconnect: () => void;
@ -23,6 +26,7 @@ export function DialogNetworkStatus({
containerWidthBreakpoint,
i18n,
isOnline,
isOutage,
socketStatus,
manualReconnect,
}: PropsType): JSX.Element | null {
@ -48,6 +52,17 @@ export function DialogNetworkStatus({
manualReconnect();
};
if (isOutage) {
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="error"
subtitle={i18n('icu:DialogNetworkStatus__outage')}
/>
);
}
if (isConnecting) {
const spinner = (
<div className="LeftPaneDialog__spinner-container">

View file

@ -201,6 +201,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
i18n={i18n}
socketStatus={SocketStatus.CLOSED}
isOnline={false}
isOutage={false}
manualReconnect={action('manualReconnect')}
{...overrideProps.dialogNetworkStatus}
{...props}

View file

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

View file

@ -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<void> => {
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);

View file

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

View file

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

View file

@ -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<any>): boolean {

View file

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