- {typeof icon === 'string' ?
: icon}
+ {icon ? (
+
+ {typeof icon === 'string' ? (
+
+ ) : (
+ icon
+ )}
+
+ ) : null}
{containerWidthBreakpoint !== WidthBreakpoint.Narrow && (
{message}
)}
@@ -192,3 +191,31 @@ export function LeftPaneDialog({
return dialogNode;
}
+
+export function LeftPaneDialogIcon({
+ type,
+}: {
+ type?: 'update' | 'relink' | 'network' | 'warning' | 'error';
+}): JSX.Element {
+ const iconClassName = classNames([
+ `${BASE_CLASS_NAME}__icon`,
+ `${BASE_CLASS_NAME}__icon--${type}`,
+ ]);
+ return
;
+}
+
+export function LeftPaneDialogIconBackground({
+ type,
+ children,
+}: {
+ type?: 'warning';
+ children: React.ReactNode;
+}): JSX.Element {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/ts/components/ServerAlerts.tsx b/ts/components/ServerAlerts.tsx
new file mode 100644
index 0000000000..2c730adca1
--- /dev/null
+++ b/ts/components/ServerAlerts.tsx
@@ -0,0 +1,63 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+import {
+ getServerAlertToShow,
+ ServerAlert,
+ type ServerAlertsType,
+} from '../util/handleServerAlerts';
+import type { WidthBreakpoint } from './_util';
+import type { LocalizerType } from '../types/I18N';
+import { CriticalIdlePrimaryDeviceDialog } from './CriticalIdlePrimaryDeviceDialog';
+import { strictAssert } from '../util/assert';
+import { WarningIdlePrimaryDeviceDialog } from './WarningIdlePrimaryDeviceDialog';
+
+export function getServerAlertDialog(
+ alerts: ServerAlertsType | undefined,
+ dialogProps: {
+ containerWidthBreakpoint: WidthBreakpoint;
+ i18n: LocalizerType;
+ }
+): JSX.Element | null {
+ if (!alerts) {
+ return null;
+ }
+ const alertToShow = getServerAlertToShow(alerts);
+ if (!alertToShow) {
+ return null;
+ }
+
+ if (alertToShow === ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE) {
+ return
;
+ }
+
+ if (alertToShow === ServerAlert.IDLE_PRIMARY_DEVICE) {
+ const alert = alerts[ServerAlert.IDLE_PRIMARY_DEVICE];
+ strictAssert(alert, 'alert must exist');
+
+ // Only allow dismissing it once
+ const isDismissable = alert.dismissedAt == null;
+
+ return (
+
{
+ await window.storage.put('serverAlerts', {
+ ...alerts,
+ [ServerAlert.IDLE_PRIMARY_DEVICE]: {
+ ...alert,
+ dismissedAt: Date.now(),
+ },
+ });
+ }
+ : undefined
+ }
+ />
+ );
+ }
+
+ return null;
+}
diff --git a/ts/components/WarningIdlePrimaryDeviceDialog.tsx b/ts/components/WarningIdlePrimaryDeviceDialog.tsx
new file mode 100644
index 0000000000..3d7eab5657
--- /dev/null
+++ b/ts/components/WarningIdlePrimaryDeviceDialog.tsx
@@ -0,0 +1,52 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import React from 'react';
+
+import {
+ LeftPaneDialog,
+ LeftPaneDialogIcon,
+ LeftPaneDialogIconBackground,
+} from './LeftPaneDialog';
+import type { WidthBreakpoint } from './_util';
+import type { LocalizerType } from '../types/I18N';
+
+export type Props = {
+ containerWidthBreakpoint: WidthBreakpoint;
+ i18n: LocalizerType;
+};
+
+const SUPPORT_PAGE =
+ 'https://support.signal.org/hc/articles/9021007554074-Open-Signal-on-your-phone-to-keep-your-account-active';
+
+export function WarningIdlePrimaryDeviceDialog({
+ containerWidthBreakpoint,
+ i18n,
+ handleClose,
+}: Props & { handleClose?: VoidFunction }): JSX.Element {
+ return (
+
+
+
+ }
+ {...(handleClose == null
+ ? { hasXButton: false }
+ : {
+ hasXButton: true,
+ onClose: handleClose,
+ closeLabel: i18n('icu:close'),
+ })}
+ >
+ {i18n('icu:IdlePrimaryDevice__body')}
+
+
+ );
+}
diff --git a/ts/state/actions.ts b/ts/state/actions.ts
index 711efeee6f..eb6fde5e47 100644
--- a/ts/state/actions.ts
+++ b/ts/state/actions.ts
@@ -23,7 +23,6 @@ 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';
@@ -56,7 +55,6 @@ 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
deleted file mode 100644
index 8a78b73e7f..0000000000
--- a/ts/state/ducks/server.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-// 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;
-}>;
-
-export function parseServerAlertFromHeader(
- headerValue: string
-): ServerAlert | undefined {
- if (headerValue.toLowerCase() === 'critical-idle-primary-device') {
- return ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE;
- }
-
- return undefined;
-}
-
-// 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 07d15e1dac..8631122985 100644
--- a/ts/state/getInitialState.ts
+++ b/ts/state/getInitialState.ts
@@ -25,7 +25,6 @@ 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';
@@ -145,7 +144,6 @@ 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 c81e9c5170..2f8b8d9643 100644
--- a/ts/state/initializeRedux.ts
+++ b/ts/state/initializeRedux.ts
@@ -83,7 +83,6 @@ 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 13079b14ba..56be010ab0 100644
--- a/ts/state/reducer.ts
+++ b/ts/state/reducer.ts
@@ -27,7 +27,6 @@ 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';
@@ -61,7 +60,6 @@ export const reducer = combineReducers({
preferredReactions,
safetyNumber,
search,
- server,
stickers,
stories,
storyDistributionLists,
diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts
index 95d15daf9f..6685e470d8 100644
--- a/ts/state/selectors/items.ts
+++ b/ts/state/selectors/items.ts
@@ -273,3 +273,8 @@ export const getBackupMediaDownloadProgress = createSelector(
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
})
);
+
+export const getServerAlerts = createSelector(
+ getItems,
+ (state: ItemsStateType) => state.serverAlerts ?? {}
+);
diff --git a/ts/state/selectors/server.ts b/ts/state/selectors/server.ts
deleted file mode 100644
index d2e221b575..0000000000
--- a/ts/state/selectors/server.ts
+++ /dev/null
@@ -1,17 +0,0 @@
-// 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 547d544307..5ef1618baa 100644
--- a/ts/state/smart/LeftPane.tsx
+++ b/ts/state/smart/LeftPane.tsx
@@ -60,6 +60,7 @@ import {
getBackupMediaDownloadProgress,
getNavTabsCollapsed,
getPreferredLeftPaneWidth,
+ getServerAlerts,
getUsernameCorrupted,
getUsernameLinkCorrupted,
} from '../selectors/items';
@@ -104,9 +105,6 @@ 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 ;
@@ -126,14 +124,6 @@ function renderUpdateDialog(
): JSX.Element {
return ;
}
-function renderCriticalIdlePrimaryDeviceDialog(
- props: Readonly<{
- containerWidthBreakpoint: WidthBreakpoint;
- i18n: LocalizerType;
- }>
-): JSX.Element {
- return ;
-}
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
return ;
}
@@ -307,9 +297,8 @@ export const SmartLeftPane = memo(function SmartLeftPane({
const backupMediaDownloadProgress = useSelector(
getBackupMediaDownloadProgress
);
- const hasCriticalIdlePrimaryDeviceAlert = useSelector(
- getHasCriticalIdlePrimaryDeviceAlert
- );
+
+ const serverAlerts = useSelector(getServerAlerts);
const {
blockConversation,
@@ -402,7 +391,6 @@ export const SmartLeftPane = memo(function SmartLeftPane({
getPreferredBadge={getPreferredBadge}
hasExpiredDialog={hasExpiredDialog}
hasFailedStorySends={hasFailedStorySends}
- hasCriticalIdlePrimaryDeviceAlert={hasCriticalIdlePrimaryDeviceAlert}
hasNetworkDialog={hasNetworkDialog}
hasPendingUpdate={hasPendingUpdate}
hasRelinkDialog={hasRelinkDialog}
@@ -424,9 +412,6 @@ export const SmartLeftPane = memo(function SmartLeftPane({
renderCaptchaDialog={renderCaptchaDialog}
renderCrashReportDialog={renderCrashReportDialog}
renderExpiredBuildDialog={renderExpiredBuildDialog}
- renderCriticalIdlePrimaryDeviceDialog={
- renderCriticalIdlePrimaryDeviceDialog
- }
renderMessageSearchResult={renderMessageSearchResult}
renderNetworkStatus={renderNetworkStatus}
renderRelinkDialog={renderRelinkDialog}
@@ -437,6 +422,7 @@ export const SmartLeftPane = memo(function SmartLeftPane({
savePreferredLeftPaneWidth={savePreferredLeftPaneWidth}
searchInConversation={searchInConversation}
selectedConversationId={selectedConversationId}
+ serverAlerts={serverAlerts}
setChallengeStatus={setChallengeStatus}
setComposeGroupAvatar={setComposeGroupAvatar}
setComposeGroupExpireTimer={setComposeGroupExpireTimer}
diff --git a/ts/state/types.ts b/ts/state/types.ts
index 6d8378fefe..e629b17929 100644
--- a/ts/state/types.ts
+++ b/ts/state/types.ts
@@ -23,7 +23,6 @@ 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';
@@ -55,7 +54,6 @@ 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-both/util/serverAlerts_test.ts b/ts/test-both/util/serverAlerts_test.ts
new file mode 100644
index 0000000000..3059b62d2c
--- /dev/null
+++ b/ts/test-both/util/serverAlerts_test.ts
@@ -0,0 +1,45 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import { assert } from 'chai';
+import {
+ getServerAlertToShow,
+ ServerAlert,
+} from '../../util/handleServerAlerts';
+import { DAY, MONTH, WEEK } from '../../util/durations';
+
+describe('serverAlerts', () => {
+ it('should prefer critical alerts', () => {
+ assert.strictEqual(
+ getServerAlertToShow({
+ [ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE]: {
+ firstReceivedAt: Date.now(),
+ },
+ [ServerAlert.IDLE_PRIMARY_DEVICE]: { firstReceivedAt: Date.now() },
+ }),
+ ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE
+ );
+ });
+ it('should not show idle device warning if dismissed < 1 week', () => {
+ assert.strictEqual(
+ getServerAlertToShow({
+ [ServerAlert.IDLE_PRIMARY_DEVICE]: {
+ firstReceivedAt: Date.now() - MONTH,
+ dismissedAt: Date.now() - DAY,
+ },
+ }),
+ null
+ );
+ });
+ it('should show idle device warning if dismissed > 1 week', () => {
+ assert.strictEqual(
+ getServerAlertToShow({
+ [ServerAlert.IDLE_PRIMARY_DEVICE]: {
+ firstReceivedAt: Date.now() - MONTH,
+ dismissedAt: Date.now() - WEEK - 1,
+ },
+ }),
+ ServerAlert.IDLE_PRIMARY_DEVICE
+ );
+ });
+});
diff --git a/ts/test-mock/network/serverAlerts_test.ts b/ts/test-mock/network/serverAlerts_test.ts
index e882148d08..64b5353ad9 100644
--- a/ts/test-mock/network/serverAlerts_test.ts
+++ b/ts/test-mock/network/serverAlerts_test.ts
@@ -1,6 +1,7 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import createDebug from 'debug';
+import type { Page } from 'playwright';
import type { App } from '../playwright';
import { Bootstrap } from '../bootstrap';
@@ -33,26 +34,60 @@ describe('serverAlerts', function (this: Mocha.Suite) {
await bootstrap.teardown();
});
- it('shows critical idle primary device alert using classic desktop socket', 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();
- });
+ const TEST_CASES = [
+ {
+ name: 'shows critical idle primary device alert',
+ headers: {
+ 'X-Signal-Alert': 'critical-idle-primary-device',
+ },
+ test: async (window: Page) => {
+ await getLeftPane(window)
+ .getByText('Your account will be deleted soon')
+ .waitFor();
+ },
+ },
+ {
+ name: 'handles different ordering of response values',
+ headers: {
+ 'X-Signal-Alert':
+ 'idle-primary-device, unknown-alert, critical-idle-primary-device',
+ },
+ test: async (window: Page) => {
+ await getLeftPane(window)
+ .getByText('Your account will be deleted soon')
+ .waitFor();
+ },
+ },
+ {
+ name: 'shows idle primary device warning',
+ headers: {
+ 'X-Signal-Alert': 'idle-primary-device',
+ },
+ test: async (window: Page) => {
+ await getLeftPane(window)
+ .getByText('Open signal on your phone to keep your account active')
+ .waitFor();
+ },
+ },
+ ] as const;
- it('shows critical idle primary device alert using libsignal socket', async () => {
- bootstrap.server.setWebsocketUpgradeResponseHeaders({
- 'X-Signal-Alert': 'critical-idle-primary-device',
- });
+ for (const testCase of TEST_CASES) {
+ for (const transport of ['classic', 'libsignal']) {
+ // eslint-disable-next-line no-loop-func
+ it(`${testCase.name}: ${transport} socket`, async () => {
+ bootstrap.server.setWebsocketUpgradeResponseHeaders(testCase.headers);
+ app =
+ transport === 'classic'
+ ? await bootstrap.link()
+ : await setupAppToUseLibsignalWebsockets(bootstrap);
+ const window = await app.getWindow();
+ await testCase.test(window);
- app = await setupAppToUseLibsignalWebsockets(bootstrap);
-
- const window = await app.getWindow();
- await getLeftPane(window).getByText('Open Signal on your phone').waitFor();
-
- debug('confirming that app was actually using libsignal');
- await assertAppWasUsingLibsignalWebsockets(app);
- });
+ if (transport === 'libsignal') {
+ debug('confirming that app was actually using libsignal');
+ await assertAppWasUsingLibsignalWebsockets(app);
+ }
+ });
+ }
+ }
});
diff --git a/ts/textsecure/SocketManager.ts b/ts/textsecure/SocketManager.ts
index f073109c95..8846550971 100644
--- a/ts/textsecure/SocketManager.ts
+++ b/ts/textsecure/SocketManager.ts
@@ -51,8 +51,10 @@ import { isNightly, isBeta, isStaging } from '../util/version';
import { getBasicAuth } from '../util/getBasicAuth';
import { isTestOrMockEnvironment } from '../environment';
import type { ConfigKeyType } from '../RemoteConfig';
-import type { ServerAlert } from '../state/ducks/server';
-import { parseServerAlertFromHeader } from '../state/ducks/server';
+import {
+ parseServerAlertsFromHeader,
+ type ServerAlert,
+} from '../util/handleServerAlerts';
const FIVE_MINUTES = 5 * durations.MINUTE;
@@ -977,8 +979,8 @@ export class SocketManager extends EventListener {
}
const serverAlerts: Array = alerts
- .map(parseServerAlertFromHeader)
- .filter(v => v !== undefined);
+ .map(parseServerAlertsFromHeader)
+ .flat();
this.emit('serverAlerts', serverAlerts);
}
diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts
index e8b43fedb3..43ee666a89 100644
--- a/ts/textsecure/WebAPI.ts
+++ b/ts/textsecure/WebAPI.ts
@@ -82,7 +82,7 @@ import { getMockServerPort } from '../util/getMockServerPort';
import { pemToDer } from '../util/pemToDer';
import { ToastType } from '../types/Toast';
import { isProduction } from '../util/version';
-import type { ServerAlert } from '../state/ducks/server';
+import type { ServerAlert } from '../util/handleServerAlerts';
// Note: this will break some code that expects to be able to use err.response when a
// web request fails, because it will force it to text. But it is very useful for
diff --git a/ts/textsecure/WebsocketResources.ts b/ts/textsecure/WebsocketResources.ts
index 463829feae..21309ae748 100644
--- a/ts/textsecure/WebsocketResources.ts
+++ b/ts/textsecure/WebsocketResources.ts
@@ -58,8 +58,10 @@ import { AbortableProcess } from '../util/AbortableProcess';
import type { WebAPICredentials } from './Types';
import { NORMAL_DISCONNECT_CODE } from './SocketManager';
import { parseUnknown } from '../util/schemas';
-import type { ServerAlert } from '../state/ducks/server';
-import { parseServerAlertFromHeader } from '../state/ducks/server';
+import {
+ parseServerAlertsFromHeader,
+ type ServerAlert,
+} from '../util/handleServerAlerts';
const THIRTY_SECONDS = 30 * durations.SECOND;
@@ -396,9 +398,7 @@ export function connectAuthenticatedLibsignal({
this.resource = undefined;
},
onReceivedAlerts(alerts: Array): void {
- onReceivedAlerts(
- alerts.map(parseServerAlertFromHeader).filter(v => v !== undefined)
- );
+ onReceivedAlerts(alerts.map(parseServerAlertsFromHeader).flat());
},
};
return connectLibsignal(
diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts
index 4a11e1a085..0c391311e5 100644
--- a/ts/types/Storage.d.ts
+++ b/ts/types/Storage.d.ts
@@ -21,6 +21,7 @@ import type { BackupCredentialWrapperType } from './backups';
import type { ServiceIdString } from './ServiceId';
import type { RegisteredChallengeType } from '../challenge';
+import type { ServerAlertsType } from '../util/handleServerAlerts';
export type AutoDownloadAttachmentType = {
photos: boolean;
@@ -194,6 +195,7 @@ export type StorageAccessType = {
entropy: Uint8Array;
serverId: Uint8Array;
};
+ serverAlerts: ServerAlertsType;
needOrphanedAttachmentCheck: boolean;
observedCapabilities: {
deleteSync?: true;
diff --git a/ts/util/handleServerAlerts.ts b/ts/util/handleServerAlerts.ts
index 027349ba7a..844e7c2fe9 100644
--- a/ts/util/handleServerAlerts.ts
+++ b/ts/util/handleServerAlerts.ts
@@ -1,8 +1,106 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
-import type { ServerAlert } from '../state/ducks/server';
+import * as log from '../logging/log';
+import { isMoreRecentThan } from './timestamp';
+import { WEEK } from './durations';
+import { isNotNil } from './isNotNil';
-export function handleServerAlerts(alerts: Array): void {
- window.reduxActions.server.updateServerAlerts(alerts);
+export enum ServerAlert {
+ CRITICAL_IDLE_PRIMARY_DEVICE = 'critical_idle_primary_device',
+ IDLE_PRIMARY_DEVICE = 'idle_primary_device',
+}
+
+export type ServerAlertsType = {
+ [ServerAlert.IDLE_PRIMARY_DEVICE]?: {
+ firstReceivedAt: number;
+ dismissedAt?: number;
+ };
+ [ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE]?: {
+ firstReceivedAt: number;
+ };
+};
+
+export function parseServerAlertsFromHeader(
+ headerValue: string
+): Array {
+ return headerValue
+ .split(',')
+ .map(value => value.toLowerCase().trim())
+ .map(header => {
+ if (header === 'critical-idle-primary-device') {
+ return ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE;
+ }
+ if (header === 'idle-primary-device') {
+ return ServerAlert.IDLE_PRIMARY_DEVICE;
+ }
+ log.warn(
+ 'parseServerAlertFromHeader: unknown server alert received',
+ headerValue
+ );
+ return null;
+ })
+ .filter(isNotNil);
+}
+
+export async function handleServerAlerts(
+ receivedAlerts: Array
+): Promise {
+ const existingAlerts = window.storage.get('serverAlerts') ?? {};
+ const existingAlertNames = new Set(Object.keys(existingAlerts));
+
+ const now = Date.now();
+ const newAlerts: ServerAlertsType = {};
+
+ for (const alert of receivedAlerts) {
+ existingAlertNames.delete(alert);
+
+ const existingAlert = existingAlerts[alert];
+ if (existingAlert) {
+ newAlerts[alert] = existingAlert;
+ } else {
+ newAlerts[alert] = {
+ firstReceivedAt: now,
+ };
+ log.info(`handleServerAlerts: got new alert: ${alert}`);
+ }
+ }
+
+ if (existingAlertNames.size > 0) {
+ log.info(
+ `handleServerAlerts: removed alerts: ${[...existingAlertNames].join(', ')}`
+ );
+ }
+
+ await window.storage.put('serverAlerts', newAlerts);
+}
+
+export function getServerAlertToShow(
+ alerts: ServerAlertsType
+): ServerAlert | null {
+ if (alerts[ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE]) {
+ return ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE;
+ }
+
+ if (
+ shouldShowIdlePrimaryDeviceAlert(alerts[ServerAlert.IDLE_PRIMARY_DEVICE])
+ ) {
+ return ServerAlert.IDLE_PRIMARY_DEVICE;
+ }
+
+ return null;
+}
+
+function shouldShowIdlePrimaryDeviceAlert(
+ alertInfo: ServerAlertsType[ServerAlert.IDLE_PRIMARY_DEVICE]
+): boolean {
+ if (!alertInfo) {
+ return false;
+ }
+
+ if (alertInfo.dismissedAt && isMoreRecentThan(alertInfo.dismissedAt, WEEK)) {
+ return false;
+ }
+
+ return true;
}