diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ae2ec91fa..8b0e2f3f4 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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" diff --git a/stylesheets/components/LeftPaneDialog.scss b/stylesheets/components/LeftPaneDialog.scss index 0085ddb5f..e529bdd91 100644 --- a/stylesheets/components/LeftPaneDialog.scss +++ b/stylesheets/components/LeftPaneDialog.scss @@ -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 { diff --git a/ts/components/DialogNetworkStatus.stories.tsx b/ts/components/DialogNetworkStatus.stories.tsx index cfbec3cc1..1b75acee6 100644 --- a/ts/components/DialogNetworkStatus.stories.tsx +++ b/ts/components/DialogNetworkStatus.stories.tsx @@ -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 ( + + + + ); +} + export function ConnectingNarrow(): JSX.Element { return ( @@ -152,3 +167,16 @@ export function OfflineNarrow(): JSX.Element { ); } + +export function OutageNarrow(): JSX.Element { + return ( + + + + ); +} diff --git a/ts/components/DialogNetworkStatus.tsx b/ts/components/DialogNetworkStatus.tsx index 8250fba14..6182db522 100644 --- a/ts/components/DialogNetworkStatus.tsx +++ b/ts/components/DialogNetworkStatus.tsx @@ -13,7 +13,10 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary'; const FIVE_SECONDS = 5 * 1000; -export type PropsType = Pick & { +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 ( + + ); + } + if (isConnecting) { const spinner = (
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'); });