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", "messageformat": "Update Downloaded",
"description": "The title of update dialog when update download is completed." "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": { "icu:InstallScreenUpdateDialog--unsupported-os__title": {
"messageformat": "Update Required", "messageformat": "Update Required",
"description": "The title of update dialog on install screen when user OS is unsupported" "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 -webkit-mask: url('../images/icons/v3/error/error-triangle.svg') no-repeat
center; center;
} }
&--error {
-webkit-mask: url('../images/icons/v3/error/error-circle.svg') no-repeat
center;
}
} }
&__action-text { &__action-text {

View file

@ -20,6 +20,7 @@ const defaultProps = {
hasNetworkDialog: true, hasNetworkDialog: true,
i18n, i18n,
isOnline: true, isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING, socketStatus: SocketStatus.CONNECTING,
manualReconnect: action('manual-reconnect'), manualReconnect: action('manual-reconnect'),
withinConnectingGracePeriod: false, withinConnectingGracePeriod: false,
@ -54,6 +55,7 @@ KnobsPlayground.args = {
containerWidthBreakpoint: WidthBreakpoint.Wide, containerWidthBreakpoint: WidthBreakpoint.Wide,
hasNetworkDialog: true, hasNetworkDialog: true,
isOnline: true, isOnline: true,
isOutage: false,
socketStatus: SocketStatus.CONNECTING, 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 { export function ConnectingNarrow(): JSX.Element {
return ( return (
<FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}> <FakeLeftPaneContainer containerWidthBreakpoint={WidthBreakpoint.Narrow}>
@ -152,3 +167,16 @@ export function OfflineNarrow(): JSX.Element {
</FakeLeftPaneContainer> </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; const FIVE_SECONDS = 5 * 1000;
export type PropsType = Pick<NetworkStateType, 'isOnline' | 'socketStatus'> & { export type PropsType = Pick<
NetworkStateType,
'isOnline' | 'isOutage' | 'socketStatus'
> & {
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType; i18n: LocalizerType;
manualReconnect: () => void; manualReconnect: () => void;
@ -23,6 +26,7 @@ export function DialogNetworkStatus({
containerWidthBreakpoint, containerWidthBreakpoint,
i18n, i18n,
isOnline, isOnline,
isOutage,
socketStatus, socketStatus,
manualReconnect, manualReconnect,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
@ -48,6 +52,17 @@ export function DialogNetworkStatus({
manualReconnect(); manualReconnect();
}; };
if (isOutage) {
return (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="warning"
icon="error"
subtitle={i18n('icu:DialogNetworkStatus__outage')}
/>
);
}
if (isConnecting) { if (isConnecting) {
const spinner = ( const spinner = (
<div className="LeftPaneDialog__spinner-container"> <div className="LeftPaneDialog__spinner-container">

View file

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

View file

@ -12,7 +12,7 @@ const TOOLTIP_CLASS_NAME = `${BASE_CLASS_NAME}__tooltip`;
export type PropsType = { export type PropsType = {
type?: 'warning' | 'error'; type?: 'warning' | 'error';
icon?: 'update' | 'relink' | 'network' | 'warning' | ReactChild; icon?: 'update' | 'relink' | 'network' | 'warning' | 'error' | ReactChild;
title?: string; title?: string;
subtitle?: string; subtitle?: string;
children?: ReactNode; children?: ReactNode;

View file

@ -8,10 +8,19 @@ import type {
import { getSocketStatus } from '../shims/socketStatus'; import { getSocketStatus } from '../shims/socketStatus';
import * as log from '../logging/log'; import * as log from '../logging/log';
import { SECOND } from '../util/durations'; 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 = { type NetworkActions = {
checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType; checkNetworkStatus: (x: CheckNetworkStatusPayloadType) => NetworkActionType;
closeConnectingGracePeriod: () => NetworkActionType; closeConnectingGracePeriod: () => NetworkActionType;
setOutage: (isOutage: boolean) => NetworkActionType;
}; };
export function initializeNetworkObserver( export function initializeNetworkObserver(
@ -26,9 +35,62 @@ export function initializeNetworkObserver(
isOnline: navigator.onLine, isOnline: navigator.onLine,
socketStatus, 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('socketStatusChange', refresh);
window.Whisper.events.on('socketConnectError', onPotentialOutage);
window.addEventListener('online', refresh); window.addEventListener('online', refresh);
window.addEventListener('offline', refresh); window.addEventListener('offline', refresh);

View file

@ -10,6 +10,7 @@ import { assignWithNoUnnecessaryAllocation } from '../../util/assignWithNoUnnece
export type NetworkStateType = ReadonlyDeep<{ export type NetworkStateType = ReadonlyDeep<{
isOnline: boolean; isOnline: boolean;
isOutage: boolean;
socketStatus: SocketStatus; socketStatus: SocketStatus;
withinConnectingGracePeriod: boolean; withinConnectingGracePeriod: boolean;
challengeStatus: 'required' | 'pending' | 'idle'; 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 CLOSE_CONNECTING_GRACE_PERIOD = 'network/CLOSE_CONNECTING_GRACE_PERIOD';
const RELINK_DEVICE = 'network/RELINK_DEVICE'; const RELINK_DEVICE = 'network/RELINK_DEVICE';
const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS'; const SET_CHALLENGE_STATUS = 'network/SET_CHALLENGE_STATUS';
const SET_OUTAGE = 'network/SET_OUTAGE';
export type CheckNetworkStatusPayloadType = ReadonlyDeep<{ export type CheckNetworkStatusPayloadType = ReadonlyDeep<{
isOnline: boolean; isOnline: boolean;
@ -47,11 +49,19 @@ type SetChallengeStatusActionType = ReadonlyDeep<{
}; };
}>; }>;
type SetOutageActionType = ReadonlyDeep<{
type: 'network/SET_OUTAGE';
payload: {
isOutage: boolean;
};
}>;
export type NetworkActionType = ReadonlyDeep< export type NetworkActionType = ReadonlyDeep<
| CheckNetworkStatusAction | CheckNetworkStatusAction
| CloseConnectingGracePeriodActionType | CloseConnectingGracePeriodActionType
| RelinkDeviceActionType | RelinkDeviceActionType
| SetChallengeStatusActionType | SetChallengeStatusActionType
| SetOutageActionType
>; >;
// Action Creators // Action Creators
@ -88,11 +98,19 @@ function setChallengeStatus(
}; };
} }
function setOutage(isOutage: boolean): SetOutageActionType {
return {
type: SET_OUTAGE,
payload: { isOutage },
};
}
export const actions = { export const actions = {
checkNetworkStatus, checkNetworkStatus,
closeConnectingGracePeriod, closeConnectingGracePeriod,
relinkDevice, relinkDevice,
setChallengeStatus, setChallengeStatus,
setOutage,
}; };
// Reducer // Reducer
@ -100,6 +118,7 @@ export const actions = {
export function getEmptyState(): NetworkStateType { export function getEmptyState(): NetworkStateType {
return { return {
isOnline: navigator.onLine, isOnline: navigator.onLine,
isOutage: false,
socketStatus: SocketStatus.OPEN, socketStatus: SocketStatus.OPEN,
withinConnectingGracePeriod: true, withinConnectingGracePeriod: true,
challengeStatus: 'idle', 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; return state;
} }

View file

@ -14,11 +14,17 @@ export const hasNetworkDialog = createSelector(
getNetwork, getNetwork,
isDone, isDone,
( (
{ isOnline, socketStatus, withinConnectingGracePeriod }: NetworkStateType, {
isOnline,
isOutage,
socketStatus,
withinConnectingGracePeriod,
}: NetworkStateType,
isRegistrationDone: boolean isRegistrationDone: boolean
): boolean => ): boolean =>
isRegistrationDone && isRegistrationDone &&
(!isOnline || (!isOnline ||
isOutage ||
(socketStatus === SocketStatus.CONNECTING && (socketStatus === SocketStatus.CONNECTING &&
!withinConnectingGracePeriod) || !withinConnectingGracePeriod) ||
socketStatus === SocketStatus.CLOSED || socketStatus === SocketStatus.CLOSED ||

View file

@ -219,6 +219,10 @@ export class SocketManager extends EventListener {
// No reconnect attempt should be made // No reconnect attempt should be made
return; return;
} }
if (code === -1) {
this.emit('connectError');
}
} }
void reconnect(); void reconnect();
@ -706,6 +710,7 @@ export class SocketManager extends EventListener {
): this; ): this;
public override on(type: 'statusChange', callback: () => void): this; public override on(type: 'statusChange', callback: () => void): this;
public override on(type: 'deviceConflict', callback: () => void): this; public override on(type: 'deviceConflict', callback: () => void): this;
public override on(type: 'connectError', callback: () => void): this;
public override on( public override on(
type: string | symbol, type: string | symbol,
@ -718,6 +723,7 @@ export class SocketManager extends EventListener {
public override emit(type: 'authError', error: HTTPError): boolean; public override emit(type: 'authError', error: HTTPError): boolean;
public override emit(type: 'statusChange'): boolean; public override emit(type: 'statusChange'): boolean;
public override emit(type: 'deviceConflict'): boolean; public override emit(type: 'deviceConflict'): boolean;
public override emit(type: 'connectError'): boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
public override emit(type: string | symbol, ...args: Array<any>): boolean { public override emit(type: string | symbol, ...args: Array<any>): boolean {

View file

@ -1325,6 +1325,10 @@ export function initialize({
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.trigger('unlinkAndDisconnect');
}); });
socketManager.on('connectError', () => {
window.Whisper.events.trigger('socketConnectError');
});
socketManager.on('deviceConflict', () => { socketManager.on('deviceConflict', () => {
window.Whisper.events.trigger('unlinkAndDisconnect'); window.Whisper.events.trigger('unlinkAndDisconnect');
}); });