From f5fe787ed75cadf359ee063f74f314648ffd1796 Mon Sep 17 00:00:00 2001
From: trevor-signal <131492920+trevor-signal@users.noreply.github.com>
Date: Thu, 6 Mar 2025 12:58:57 -0500
Subject: [PATCH] Show critical-idle-primary-device banner in response to WS
upgrade response headers
---
_locales/en/messages.json | 8 +++
package.json | 3 +-
patches/@types+websocket+1.0.0.patch | 16 +++++
patches/websocket+1.0.34.patch | 14 ++++-
pnpm-lock.yaml | 23 ++++----
ts/background.ts | 3 +
.../CriticalIdlePrimaryDeviceDialog.tsx | 46 +++++++++++++++
ts/components/LeftPane.stories.tsx | 14 +++++
ts/components/LeftPane.tsx | 12 ++++
ts/state/actions.ts | 2 +
ts/state/ducks/server.ts | 59 +++++++++++++++++++
ts/state/getInitialState.ts | 2 +
ts/state/initializeRedux.ts | 1 +
ts/state/reducer.ts | 2 +
ts/state/selectors/server.ts | 17 ++++++
ts/state/smart/LeftPane.tsx | 20 ++++++-
ts/state/types.ts | 2 +
ts/test-mock/helpers.ts | 5 +-
ts/test-mock/network/serverAlerts_test.ts | 40 +++++++++++++
ts/textsecure/SocketManager.ts | 43 ++++++++++++++
ts/textsecure/WebAPI.ts | 4 ++
ts/textsecure/WebSocket.ts | 7 +++
ts/util/handleServerAlerts.ts | 8 +++
23 files changed, 337 insertions(+), 14 deletions(-)
create mode 100644 patches/@types+websocket+1.0.0.patch
create mode 100644 ts/components/CriticalIdlePrimaryDeviceDialog.tsx
create mode 100644 ts/state/ducks/server.ts
create mode 100644 ts/state/selectors/server.ts
create mode 100644 ts/test-mock/network/serverAlerts_test.ts
create mode 100644 ts/util/handleServerAlerts.ts
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 2a1a46904..e345ec21c 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -6675,6 +6675,14 @@
"messageformat": "Update Downloaded",
"description": "The title of update dialog when update download is completed."
},
+ "icu:CriticalIdlePrimaryDevice__title": {
+ "messageformat": "Open Signal on your phone",
+ "description": "Title of a banner alerting users if their primary device (e.g. phone) has not been logged into recently."
+ },
+ "icu:CriticalIdlePrimaryDevice__body": {
+ "messageformat": "Your account will be deleted soon unless you open Signal on your phone. This message will go away if you've done it successfully. Learn more",
+ "description": "The text in a banner alerting users if their primary device (e.g. phone) has not been logged into recently. "
+ },
"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."
diff --git a/package.json b/package.json
index 86934c809..407cfede6 100644
--- a/package.json
+++ b/package.json
@@ -221,7 +221,7 @@
"@indutny/parallel-prettier": "3.0.0",
"@indutny/rezip-electron": "2.0.1",
"@napi-rs/canvas": "0.1.61",
- "@signalapp/mock-server": "11.0.0",
+ "@signalapp/mock-server": "11.1.0",
"@storybook/addon-a11y": "8.4.4",
"@storybook/addon-actions": "8.4.4",
"@storybook/addon-controls": "8.4.4",
@@ -374,6 +374,7 @@
"app-builder-lib@26.0.0-alpha.8": "patches/app-builder-lib+26.0.0-alpha.8.patch",
"growing-file@0.1.3": "patches/growing-file+0.1.3.patch",
"websocket@1.0.34": "patches/websocket+1.0.34.patch",
+ "@types/websocket@1.0.0": "patches/@types+websocket+1.0.0.patch",
"backbone@1.6.0": "patches/backbone+1.6.0.patch",
"node-fetch@2.6.7": "patches/node-fetch+2.6.7.patch",
"zod@3.23.8": "patches/zod+3.23.8.patch"
diff --git a/patches/@types+websocket+1.0.0.patch b/patches/@types+websocket+1.0.0.patch
new file mode 100644
index 000000000..5e76694fe
--- /dev/null
+++ b/patches/@types+websocket+1.0.0.patch
@@ -0,0 +1,16 @@
+diff --git a/index.d.ts b/index.d.ts
+index eb23f3c479e6d368e1563de7531a8459566d5c56..146769d33ce61f036371c05743cd31fb44d25d49 100644
+--- a/index.d.ts
++++ b/index.d.ts
+@@ -650,9 +650,11 @@ export class client extends events.EventEmitter {
+ on(event: 'connect', cb: (connection: connection) => void): this;
+ on(event: 'connectFailed', cb: (err: Error) => void): this;
+ on(event: 'httpResponse', cb: (response: http.IncomingMessage, client: client) => void): this;
++ on(event: 'upgradeResponse', cb: (response: http.IncomingMessage) => void): this;
+ addListener(event: 'connect', cb: (connection: connection) => void): this;
+ addListener(event: 'connectFailed', cb: (err: Error) => void): this;
+ addListener(event: 'httpResponse', cb: (response: http.IncomingMessage, client: client) => void): this;
++ addListener(event: 'upgradeResponse', cb: (response: http.IncomingMessage) => void): this;
+ }
+
+ export interface IRouterRequest extends events.EventEmitter {
diff --git a/patches/websocket+1.0.34.patch b/patches/websocket+1.0.34.patch
index 1a33914b4..783ecaca1 100644
--- a/patches/websocket+1.0.34.patch
+++ b/patches/websocket+1.0.34.patch
@@ -1,5 +1,17 @@
+diff --git a/lib/WebSocketClient.js b/lib/WebSocketClient.js
+index fb7572b382de289f7fa3ff35a9647f118f3bd4eb..0000a94607520b4b7ac71f5eee4ed297df8d1bf7 100644
+--- a/lib/WebSocketClient.js
++++ b/lib/WebSocketClient.js
+@@ -258,6 +258,7 @@ WebSocketClient.prototype.connect = function(requestUrl, protocols, origin, head
+ self.socket = socket;
+ self.response = response;
+ self.firstDataChunk = head;
++ self.emit('upgradeResponse', response);
+ self.validateHandshake();
+ });
+ req.on('error', handleRequestError);
diff --git a/lib/WebSocketConnection.js b/lib/WebSocketConnection.js
-index 219de63..93d3800 100644
+index 219de631dabe578e64574732fc95353a1c1047fa..93d380004b5bc58ba9895fb5a760709389641071 100644
--- a/lib/WebSocketConnection.js
+++ b/lib/WebSocketConnection.js
@@ -271,7 +271,7 @@ WebSocketConnection.prototype.handleSocketData = function(data) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 51dc72bdf..b6185b202 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -26,6 +26,9 @@ patchedDependencies:
'@types/node-fetch@2.6.12':
hash: bc43fb8cfed85fb4f7917b5bd3b47644ae15a000c26a6fe23078b2f5217efaf0
path: patches/@types+node-fetch+2.6.12.patch
+ '@types/websocket@1.0.0':
+ hash: 53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa
+ path: patches/@types+websocket+1.0.0.patch
'@vitest/expect@2.0.5':
hash: e8a96f71e52bf903c9f1eadba4740489a0beb48da33db52354adca484fe1f495
path: patches/@vitest+expect+2.0.5.patch
@@ -72,7 +75,7 @@ patchedDependencies:
hash: 94614db18e4db7ff5eee4c2acf88e3ef245bff2f9637416e99e401bec3531286
path: patches/react-textarea-autosize+8.5.5.patch
websocket@1.0.34:
- hash: 4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb
+ hash: b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355
path: patches/websocket+1.0.34.patch
zod@3.23.8:
hash: 239818e5d88990616205c8cdc1de1660bf5e18b157d00c4a5f726dde6094af4d
@@ -375,7 +378,7 @@ importers:
version: 11.0.2
websocket:
specifier: 1.0.34
- version: 1.0.34(patch_hash=4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb)
+ version: 1.0.34(patch_hash=b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355)
write-file-atomic:
specifier: 6.0.0
version: 6.0.0
@@ -429,8 +432,8 @@ importers:
specifier: 0.1.61
version: 0.1.61
'@signalapp/mock-server':
- specifier: 11.0.0
- version: 11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
+ specifier: 11.1.0
+ version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
'@storybook/addon-a11y':
specifier: 8.4.4
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
@@ -586,7 +589,7 @@ importers:
version: 10.0.0
'@types/websocket':
specifier: 1.0.0
- version: 1.0.0
+ version: 1.0.0(patch_hash=53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa)
'@types/write-file-atomic':
specifier: 4.0.3
version: 4.0.3
@@ -2535,8 +2538,8 @@ packages:
'@signalapp/libsignal-client@0.67.3':
resolution: {integrity: sha512-GIiXJMqiIByPZbomytoYQcQLJ3pNgHBCjt5BvlE/3rkrmNwyXW1UVqszX6/WfZi91aqUapO4+7Op+8JCbDGRWA==}
- '@signalapp/mock-server@11.0.0':
- resolution: {integrity: sha512-JHEqdjXvWcXyLJ90OtaTIQVtizH/w+rmnPplNmHuUiDZIQIEvE3lRyZFyIvgVTNg29lQWs/Gw/o+hICerdOChQ==}
+ '@signalapp/mock-server@11.1.0':
+ resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==}
'@signalapp/parchment-cjs@3.0.1':
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
@@ -12266,7 +12269,7 @@ snapshots:
type-fest: 4.26.1
uuid: 8.3.2
- '@signalapp/mock-server@11.0.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
+ '@signalapp/mock-server@11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
dependencies:
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
'@signalapp/libsignal-client': 0.60.2
@@ -13134,7 +13137,7 @@ snapshots:
dependencies:
'@types/node': 20.17.6
- '@types/websocket@1.0.0':
+ '@types/websocket@1.0.0(patch_hash=53fdd65a6b35f379eb8f5807d315f00c66e9bfe9741f4859a0fa21026bdb30fa)':
dependencies:
'@types/node': 20.17.6
@@ -20849,7 +20852,7 @@ snapshots:
websocket-extensions@0.1.4: {}
- websocket@1.0.34(patch_hash=4ce5bb237501a5576bd9e09fbff46551dd3e57f7702f6033bcffa49dde7f71bb):
+ websocket@1.0.34(patch_hash=b8d361a6a73e44000bb51102dea0d841c22d2bb455dd6c54de566d0e0bd86355):
dependencies:
bufferutil: 4.0.9
debug: 2.6.9
diff --git a/ts/background.ts b/ts/background.ts
index 6118e01e8..df9c92a49 100644
--- a/ts/background.ts
+++ b/ts/background.ts
@@ -208,6 +208,7 @@ import { handleDataMessage } from './messages/handleDataMessage';
import { MessageModel } from './models/messages';
import { waitForEvent } from './shims/events';
import { sendSyncRequests } from './textsecure/syncRequests';
+import { handleServerAlerts } from './util/handleServerAlerts';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@@ -511,6 +512,8 @@ export async function startApp(): Promise {
restoreRemoteConfigFromStorage();
window.Whisper.events.on('firstEnvelope', checkFirstEnvelope);
+ window.Whisper.events.on('serverAlerts', handleServerAlerts);
+
server = window.WebAPI.connect({
...window.textsecure.storage.user.getWebAPICredentials(),
hasStoriesDisabled: window.storage.get('hasStoriesDisabled', false),
diff --git a/ts/components/CriticalIdlePrimaryDeviceDialog.tsx b/ts/components/CriticalIdlePrimaryDeviceDialog.tsx
new file mode 100644
index 000000000..53cd6f573
--- /dev/null
+++ b/ts/components/CriticalIdlePrimaryDeviceDialog.tsx
@@ -0,0 +1,46 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+
+import { LeftPaneDialog } from './LeftPaneDialog';
+import type { WidthBreakpoint } from './_util';
+import type { LocalizerType } from '../types/I18N';
+import { I18n } from './I18n';
+
+export type PropsType = {
+ containerWidthBreakpoint: WidthBreakpoint;
+ i18n: LocalizerType;
+};
+
+export function CriticalIdlePrimaryDeviceDialog({
+ containerWidthBreakpoint,
+ i18n,
+}: PropsType): JSX.Element {
+ const learnMoreLink = (parts: Array) => (
+
+ {parts}
+
+ );
+ return (
+
+
+
+ );
+}
diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx
index bc2d02bb8..66a0c607e 100644
--- a/ts/components/LeftPane.stories.tsx
+++ b/ts/components/LeftPane.stories.tsx
@@ -35,6 +35,7 @@ import {
useUuidFetchState,
} from '../test-both/helpers/fakeLookupConversationWithoutServiceId';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
+import { CriticalIdlePrimaryDeviceDialog } from './CriticalIdlePrimaryDeviceDialog';
const i18n = setupI18n('en', enMessages);
@@ -173,6 +174,7 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
getPreferredBadge: () => undefined,
hasFailedStorySends: false,
hasPendingUpdate: false,
+ hasCriticalIdlePrimaryDeviceAlert: false,
i18n,
isMacOS: false,
preferredWidthFromStorage: 320,
@@ -271,6 +273,9 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
/>
),
renderExpiredBuildDialog: props => ,
+ renderCriticalIdlePrimaryDeviceDialog: props => (
+
+ ),
renderUnsupportedOSDialog: props => (
);
}
+export function InboxCriticalIdlePrimaryDeviceAlert(): JSX.Element {
+ return (
+
+ );
+}
export function InboxUsernameCorrupted(): JSX.Element {
return (
diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx
index e1b0801bb..26146bfae 100644
--- a/ts/components/LeftPane.tsx
+++ b/ts/components/LeftPane.tsx
@@ -69,6 +69,7 @@ export type PropsType = {
otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
+ hasCriticalIdlePrimaryDeviceAlert: boolean;
hasNetworkDialog: boolean;
hasPendingUpdate: boolean;
hasRelinkDialog: boolean;
@@ -180,6 +181,12 @@ export type PropsType = {
renderCaptchaDialog: (props: { onSkip(): void }) => JSX.Element;
renderCrashReportDialog: () => JSX.Element;
renderExpiredBuildDialog: (_: DialogExpiredBuildPropsType) => JSX.Element;
+ renderCriticalIdlePrimaryDeviceDialog: (
+ _: Readonly<{
+ containerWidthBreakpoint: WidthBreakpoint;
+ i18n: LocalizerType;
+ }>
+ ) => JSX.Element;
renderToastManager: (_: {
containerWidthBreakpoint: WidthBreakpoint;
}) => JSX.Element;
@@ -206,6 +213,7 @@ export function LeftPane({
getPreferredBadge,
hasExpiredDialog,
hasFailedStorySends,
+ hasCriticalIdlePrimaryDeviceAlert,
hasNetworkDialog,
hasPendingUpdate,
hasRelinkDialog,
@@ -227,6 +235,7 @@ export function LeftPane({
renderCaptchaDialog,
renderCrashReportDialog,
renderExpiredBuildDialog,
+ renderCriticalIdlePrimaryDeviceDialog,
renderMessageSearchResult,
renderNetworkStatus,
renderUnsupportedOSDialog,
@@ -611,6 +620,9 @@ export function LeftPane({
maybeYellowDialog = renderNetworkStatus(commonDialogProps);
} else if (hasRelinkDialog) {
maybeYellowDialog = renderRelinkDialog(commonDialogProps);
+ } else if (hasCriticalIdlePrimaryDeviceAlert) {
+ maybeYellowDialog =
+ renderCriticalIdlePrimaryDeviceDialog(commonDialogProps);
}
// Update dialog
diff --git a/ts/state/actions.ts b/ts/state/actions.ts
index eb6fde5e4..711efeee6 100644
--- a/ts/state/actions.ts
+++ b/ts/state/actions.ts
@@ -23,6 +23,7 @@ import { actions as mediaGallery } from './ducks/mediaGallery';
import { actions as network } from './ducks/network';
import { actions as safetyNumber } from './ducks/safetyNumber';
import { actions as search } from './ducks/search';
+import { actions as server } from './ducks/server';
import { actions as stickers } from './ducks/stickers';
import { actions as stories } from './ducks/stories';
import { actions as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -55,6 +56,7 @@ export const actionCreators: ReduxActions = {
network,
safetyNumber,
search,
+ server,
stickers,
stories,
storyDistributionLists,
diff --git a/ts/state/ducks/server.ts b/ts/state/ducks/server.ts
new file mode 100644
index 000000000..adf560773
--- /dev/null
+++ b/ts/state/ducks/server.ts
@@ -0,0 +1,59 @@
+// Copyright 2020 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { ReadonlyDeep } from 'type-fest';
+
+// State
+
+export enum ServerAlert {
+ CRITICAL_IDLE_PRIMARY_DEVICE = 'critical_idle_primary_device',
+}
+
+export type ServerStateType = ReadonlyDeep<{
+ alerts: Array;
+}>;
+
+// Actions
+
+const UPDATE_SERVER_ALERTS = 'server/UPDATE_SERVER_ALERTS';
+
+type UpdateServerAlertsType = ReadonlyDeep<{
+ type: 'server/UPDATE_SERVER_ALERTS';
+ payload: { alerts: Array };
+}>;
+
+export type ServerActionType = ReadonlyDeep;
+
+// Action Creators
+
+function updateServerAlerts(alerts: Array): ServerActionType {
+ return {
+ type: UPDATE_SERVER_ALERTS,
+ payload: { alerts },
+ };
+}
+
+export const actions = {
+ updateServerAlerts,
+};
+
+// Reducer
+
+export function getEmptyState(): ServerStateType {
+ return {
+ alerts: [],
+ };
+}
+
+export function reducer(
+ state: Readonly = getEmptyState(),
+ action: Readonly
+): ServerStateType {
+ if (action.type === UPDATE_SERVER_ALERTS) {
+ return {
+ alerts: action.payload.alerts,
+ };
+ }
+
+ return state;
+}
diff --git a/ts/state/getInitialState.ts b/ts/state/getInitialState.ts
index 863112298..07d15e1da 100644
--- a/ts/state/getInitialState.ts
+++ b/ts/state/getInitialState.ts
@@ -25,6 +25,7 @@ import { getEmptyState as networkEmptyState } from './ducks/network';
import { getEmptyState as preferredReactionsEmptyState } from './ducks/preferredReactions';
import { getEmptyState as safetyNumberEmptyState } from './ducks/safetyNumber';
import { getEmptyState as searchEmptyState } from './ducks/search';
+import { getEmptyState as serverEmptyState } from './ducks/server';
import { getEmptyState as stickersEmptyState } from './ducks/stickers';
import { getEmptyState as storiesEmptyState } from './ducks/stories';
import { getEmptyState as storyDistributionListsEmptyState } from './ducks/storyDistributionLists';
@@ -144,6 +145,7 @@ function getEmptyState(): StateType {
preferredReactions: preferredReactionsEmptyState(),
safetyNumber: safetyNumberEmptyState(),
search: searchEmptyState(),
+ server: serverEmptyState(),
stickers: stickersEmptyState(),
stories: storiesEmptyState(),
storyDistributionLists: storyDistributionListsEmptyState(),
diff --git a/ts/state/initializeRedux.ts b/ts/state/initializeRedux.ts
index 2f8b8d964..c81e9c517 100644
--- a/ts/state/initializeRedux.ts
+++ b/ts/state/initializeRedux.ts
@@ -83,6 +83,7 @@ export function initializeRedux(data: ReduxInitData): void {
store.dispatch
),
search: bindActionCreators(actionCreators.search, store.dispatch),
+ server: bindActionCreators(actionCreators.server, store.dispatch),
stickers: bindActionCreators(actionCreators.stickers, store.dispatch),
stories: bindActionCreators(actionCreators.stories, store.dispatch),
storyDistributionLists: bindActionCreators(
diff --git a/ts/state/reducer.ts b/ts/state/reducer.ts
index 56be010ab..13079b14b 100644
--- a/ts/state/reducer.ts
+++ b/ts/state/reducer.ts
@@ -27,6 +27,7 @@ import { reducer as network } from './ducks/network';
import { reducer as preferredReactions } from './ducks/preferredReactions';
import { reducer as safetyNumber } from './ducks/safetyNumber';
import { reducer as search } from './ducks/search';
+import { reducer as server } from './ducks/server';
import { reducer as stickers } from './ducks/stickers';
import { reducer as stories } from './ducks/stories';
import { reducer as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -60,6 +61,7 @@ export const reducer = combineReducers({
preferredReactions,
safetyNumber,
search,
+ server,
stickers,
stories,
storyDistributionLists,
diff --git a/ts/state/selectors/server.ts b/ts/state/selectors/server.ts
new file mode 100644
index 000000000..d2e221b57
--- /dev/null
+++ b/ts/state/selectors/server.ts
@@ -0,0 +1,17 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { createSelector } from 'reselect';
+
+import type { StateType } from '../reducer';
+import { ServerAlert } from '../ducks/server';
+
+export const getServerAlerts = (state: StateType): ReadonlyArray =>
+ state.server.alerts;
+
+export const getHasCriticalIdlePrimaryDeviceAlert = createSelector(
+ getServerAlerts,
+ (alerts): boolean => {
+ return alerts.includes(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
+ }
+);
diff --git a/ts/state/smart/LeftPane.tsx b/ts/state/smart/LeftPane.tsx
index a3a5efa5d..547d54430 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -104,6 +104,9 @@ import {
pauseBackupMediaDownload,
resumeBackupMediaDownload,
} from '../../util/backupMediaDownload';
+import { getHasCriticalIdlePrimaryDeviceAlert } from '../selectors/server';
+import { CriticalIdlePrimaryDeviceDialog } from '../../components/CriticalIdlePrimaryDeviceDialog';
+import type { LocalizerType } from '../../types/I18N';
function renderMessageSearchResult(id: string): JSX.Element {
return ;
@@ -123,7 +126,14 @@ function renderUpdateDialog(
): JSX.Element {
return ;
}
-
+function renderCriticalIdlePrimaryDeviceDialog(
+ props: Readonly<{
+ containerWidthBreakpoint: WidthBreakpoint;
+ i18n: LocalizerType;
+ }>
+): JSX.Element {
+ return ;
+}
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
return ;
}
@@ -297,6 +307,10 @@ export const SmartLeftPane = memo(function SmartLeftPane({
const backupMediaDownloadProgress = useSelector(
getBackupMediaDownloadProgress
);
+ const hasCriticalIdlePrimaryDeviceAlert = useSelector(
+ getHasCriticalIdlePrimaryDeviceAlert
+ );
+
const {
blockConversation,
clearGroupCreationError,
@@ -388,6 +402,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
getPreferredBadge={getPreferredBadge}
hasExpiredDialog={hasExpiredDialog}
hasFailedStorySends={hasFailedStorySends}
+ hasCriticalIdlePrimaryDeviceAlert={hasCriticalIdlePrimaryDeviceAlert}
hasNetworkDialog={hasNetworkDialog}
hasPendingUpdate={hasPendingUpdate}
hasRelinkDialog={hasRelinkDialog}
@@ -409,6 +424,9 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog}
renderExpiredBuildDialog={renderExpiredBuildDialog}
+ renderCriticalIdlePrimaryDeviceDialog={
+ renderCriticalIdlePrimaryDeviceDialog
+ }
renderMessageSearchResult={renderMessageSearchResult}
renderNetworkStatus={renderNetworkStatus}
renderRelinkDialog={renderRelinkDialog}
diff --git a/ts/state/types.ts b/ts/state/types.ts
index e629b1792..6d8378fef 100644
--- a/ts/state/types.ts
+++ b/ts/state/types.ts
@@ -23,6 +23,7 @@ import type { actions as mediaGallery } from './ducks/mediaGallery';
import type { actions as network } from './ducks/network';
import type { actions as safetyNumber } from './ducks/safetyNumber';
import type { actions as search } from './ducks/search';
+import type { actions as server } from './ducks/server';
import type { actions as stickers } from './ducks/stickers';
import type { actions as stories } from './ducks/stories';
import type { actions as storyDistributionLists } from './ducks/storyDistributionLists';
@@ -54,6 +55,7 @@ export type ReduxActions = {
network: typeof network;
safetyNumber: typeof safetyNumber;
search: typeof search;
+ server: typeof server;
stickers: typeof stickers;
stories: typeof stories;
storyDistributionLists: typeof storyDistributionLists;
diff --git a/ts/test-mock/helpers.ts b/ts/test-mock/helpers.ts
index 4bcf8c773..12a7c02a4 100644
--- a/ts/test-mock/helpers.ts
+++ b/ts/test-mock/helpers.ts
@@ -252,12 +252,15 @@ export async function createGroup(
await phone.setStorageState(state);
return group;
}
+export function getLeftPane(page: Page): Locator {
+ return page.locator('#LeftPane');
+}
export async function clickOnConversationWithAci(
page: Page,
aci: string
): Promise {
- const leftPane = page.locator('#LeftPane');
+ const leftPane = getLeftPane(page);
await leftPane.getByTestId(aci).click();
}
diff --git a/ts/test-mock/network/serverAlerts_test.ts b/ts/test-mock/network/serverAlerts_test.ts
new file mode 100644
index 000000000..d27a9bfc5
--- /dev/null
+++ b/ts/test-mock/network/serverAlerts_test.ts
@@ -0,0 +1,40 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+import createDebug from 'debug';
+
+import type { App } from '../playwright';
+import { Bootstrap } from '../bootstrap';
+import { getLeftPane } from '../helpers';
+import { MINUTE } from '../../util/durations';
+
+export const debug = createDebug('mock:test:serverAlerts');
+
+describe('serverAlerts', function (this: Mocha.Suite) {
+ this.timeout(MINUTE);
+ let bootstrap: Bootstrap;
+ let app: App;
+
+ beforeEach(async () => {
+ bootstrap = new Bootstrap();
+ await bootstrap.init();
+ });
+
+ afterEach(async function (this: Mocha.Context) {
+ if (!bootstrap) {
+ return;
+ }
+
+ await bootstrap.maybeSaveLogs(this.currentTest, app);
+ await app.close();
+ await bootstrap.teardown();
+ });
+
+ it('shows idle primary device alert', async () => {
+ bootstrap.server.setWebsocketUpgradeResponseHeaders({
+ 'X-Signal-Alert': 'critical-idle-primary-device',
+ });
+ app = await bootstrap.link();
+ const window = await app.getWindow();
+ await getLeftPane(window).getByText('Open Signal on your phone').waitFor();
+ });
+});
diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts
index 1d808b890..6b85f8e0f 100644
--- a/ts/textsecure/SocketManager.ts
+++ b/ts/textsecure/SocketManager.ts
@@ -12,6 +12,7 @@ import { Headers } from 'node-fetch';
import type { connection as WebSocket } from 'websocket';
import qs from 'querystring';
import EventListener from 'events';
+import type { IncomingMessage } from 'http';
import type { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert';
@@ -50,6 +51,7 @@ import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment';
import type { ConfigKeyType } from '../RemoteConfig';
+import { ServerAlert } from '../state/ducks/server';
const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -220,6 +222,9 @@ export class SocketManager extends EventListener {
Authorization: getBasicAuth({ username, password }),
'X-Signal-Receive-Stories': String(!this.#hasStoriesDisabled),
},
+ onUpgradeResponse: (response: IncomingMessage) => {
+ this.#handleAuthenticatedUpgradeResponseHeaders(response.headers);
+ },
proxyAgent,
});
@@ -758,6 +763,7 @@ export class SocketManager extends EventListener {
resourceOptions,
query = {},
extraHeaders = {},
+ onUpgradeResponse,
timeout,
}: {
name: string;
@@ -766,6 +772,7 @@ export class SocketManager extends EventListener {
resourceOptions: WebSocketResourceOptions;
query?: Record;
extraHeaders?: Record;
+ onUpgradeResponse?: (response: IncomingMessage) => void;
timeout?: number;
}): AbortableProcess {
const queryWithDefaults = {
@@ -787,6 +794,7 @@ export class SocketManager extends EventListener {
timeout,
extraHeaders,
+ onUpgradeResponse,
createResource(socket: WebSocket): WebSocketResource {
const duration = (performance.now() - start).toFixed(1);
@@ -947,6 +955,33 @@ export class SocketManager extends EventListener {
return this.#lazyProxyAgent;
}
+ #handleAuthenticatedUpgradeResponseHeaders(
+ headers: Record | undefined>
+ ) {
+ let alerts: Array = [];
+ for (const [key, value] of Object.entries(headers)) {
+ if (key.toLowerCase() === 'x-signal-alert') {
+ if (value == null) {
+ alerts = [];
+ } else if (Array.isArray(value)) {
+ alerts = value;
+ } else {
+ alerts = [value];
+ }
+ break;
+ }
+ }
+
+ const serverAlerts: Array = [];
+ alerts.forEach(alert => {
+ if (alert.toLowerCase() === 'critical-idle-primary-device') {
+ serverAlerts.push(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
+ }
+ });
+
+ this.emit('serverAlerts', serverAlerts);
+ }
+
// EventEmitter types
public override on(type: 'authError', callback: () => void): this;
@@ -957,6 +992,10 @@ export class SocketManager extends EventListener {
type: 'firstEnvelope',
callback: (incoming: IncomingWebSocketRequest) => void
): this;
+ public override on(
+ type: 'serverAlerts',
+ callback: (alerts: Array) => void
+ ): this;
public override on(
type: string | symbol,
@@ -974,6 +1013,10 @@ export class SocketManager extends EventListener {
type: 'firstEnvelope',
incoming: IncomingWebSocketRequest
): boolean;
+ public override emit(
+ type: 'serverAlerts',
+ alerts: Array
+ ): 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 ed8363e90..a04d6e71e 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -1812,6 +1812,10 @@ export function initialize({
window.Whisper.events.trigger('firstEnvelope', incoming);
});
+ socketManager.on('serverAlerts', alerts => {
+ window.Whisper.events.trigger('serverAlerts', alerts);
+ });
+
if (useWebSocket) {
void socketManager.authenticate({ username, password });
}
diff --git a/ts/textsecure/WebSocket.ts b/ts/textsecure/WebSocket.ts
index 23f1c4a03..5c7c3577c 100644
--- a/ts/textsecure/WebSocket.ts
+++ b/ts/textsecure/WebSocket.ts
@@ -3,6 +3,7 @@
import { client as WebSocketClient } from 'websocket';
import type { connection as WebSocket } from 'websocket';
+import type { IncomingMessage } from 'http';
import { AbortableProcess } from '../util/AbortableProcess';
import { strictAssert } from '../util/assert';
@@ -32,6 +33,7 @@ export type ConnectOptionsType = Readonly<{
proxyAgent?: ProxyAgent;
timeout?: number;
extraHeaders?: Record;
+ onUpgradeResponse?: (response: IncomingMessage) => void;
createResource(socket: WebSocket): Resource;
}>;
@@ -44,6 +46,7 @@ export function connect({
proxyAgent,
extraHeaders = {},
timeout = WEBSOCKET_CONNECT_TIMEOUT,
+ onUpgradeResponse,
createResource,
}: ConnectOptionsType): AbortableProcess {
const fixedScheme = url
@@ -84,6 +87,10 @@ export function connect({
resolve(resource);
});
+ client.on('upgradeResponse', response => {
+ onUpgradeResponse?.(response);
+ });
+
client.on('httpResponse', async response => {
Timers.clearTimeout(timer);
diff --git a/ts/util/handleServerAlerts.ts b/ts/util/handleServerAlerts.ts
new file mode 100644
index 000000000..027349ba7
--- /dev/null
+++ b/ts/util/handleServerAlerts.ts
@@ -0,0 +1,8 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { ServerAlert } from '../state/ducks/server';
+
+export function handleServerAlerts(alerts: Array): void {
+ window.reduxActions.server.updateServerAlerts(alerts);
+}