Support idle primary device warning alert from server

This commit is contained in:
trevor-signal
2025-03-14 10:23:47 -04:00
committed by GitHub
parent de19bb07fa
commit 7c0cbb58ef
25 changed files with 520 additions and 183 deletions

View File

@@ -6683,6 +6683,14 @@
"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. <learnMoreLink>Learn more</learnMoreLink>",
"description": "The text in a banner alerting users if their primary device (e.g. phone) has not been logged into recently. "
},
"icu:IdlePrimaryDevice__body": {
"messageformat": "Open Signal on your phone to keep your account active",
"description": "The text in a banner alerting users if their primary device (e.g. phone) has not been logged into recently. "
},
"icu:IdlePrimaryDevice__learnMore": {
"messageformat": "Learn more",
"description": "The text in a link that will open a support page URL with more information"
},
"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."

View File

@@ -21,7 +21,6 @@
$warning-background-color: variables.$color-accent-yellow;
$warning-text-color: variables.$color-black;
align-items: center;
background: $default-background-color;
color: $default-text-color;
cursor: inherit;
@@ -37,10 +36,6 @@
letter-spacing: -0.0025em;
font-weight: 400;
&--width-narrow {
padding-inline-start: 36px;
}
&__retry {
@include mixins.button-reset;
& {
@@ -58,6 +53,10 @@
flex-grow: 1;
}
&--width-narrow &__container {
justify-content: center;
}
&__container-close {
display: flex;
justify-content: flex-end;
@@ -82,10 +81,21 @@
}
}
&__icon {
&__icon-container {
width: 24px;
height: 24px;
flex-shrink: 0;
margin-inline-end: 18px;
position: relative;
}
&--width-narrow &__icon-container {
margin-inline-end: 0;
}
&__icon {
width: 100%;
height: 100%;
background-color: variables.$color-white;
-webkit-mask-size: contain;
@@ -119,6 +129,25 @@
}
}
&__icon-background {
width: 100%;
height: 100%;
border-radius: 50%;
outline-width: 5px;
outline-offset: -1px; // avoids a gap between background-color and outline
outline-style: solid;
&--warning {
outline-color: $warning-background-color;
background-color: $warning-background-color;
.LeftPaneDialog__icon {
background-color: $warning-text-color;
@media (forced-colors: active) {
background-color: WindowText;
}
}
}
}
&__action-text {
@include mixins.button-reset;
& {
@@ -227,6 +256,46 @@
}
}
&--info {
width: unset;
margin-inline: 10px;
margin-block-end: 6px;
margin-block-start: 2px;
border-radius: 10px;
color: inherit;
@include mixins.light-theme {
background-color: variables.$color-white;
--tooltip-background-color: variables.$color-white;
border: 1px solid variables.$color-gray-20;
}
@include mixins.dark-theme {
background: variables.$color-gray-75;
border: 1px solid variables.$color-gray-60;
}
.LeftPaneDialog__close-button::before {
@include mixins.light-theme {
background-color: variables.$color-gray-45;
}
@include mixins.dark-theme {
background-color: variables.$color-gray-20;
}
}
}
&--info &__action-text {
@include mixins.button-reset;
& {
@include mixins.font-subtitle-bold;
@include mixins.light-theme {
color: variables.$color-ultramarine;
}
@include mixins.dark-theme {
color: variables.$color-ultramarine-light;
}
}
}
&--warning {
background-color: $warning-background-color;
color: $warning-text-color;

View File

@@ -1930,7 +1930,7 @@ export async function startApp(): Promise<void> {
log.info('afterAuthSocketConnect/afterEveryAuthConnect');
strictAssert(server, 'afterEveryAuthConnect: server');
handleServerAlerts(server.getServerAlerts());
drop(handleServerAlerts(server.getServerAlerts()));
strictAssert(challengeHandler, 'afterEveryAuthConnect: challengeHandler');
drop(challengeHandler.onOnline());

View File

@@ -33,7 +33,7 @@ import {
useUuidFetchState,
} from '../test-both/helpers/fakeLookupConversationWithoutServiceId';
import type { GroupListItemConversationType } from './conversationList/GroupListItem';
import { CriticalIdlePrimaryDeviceDialog } from './CriticalIdlePrimaryDeviceDialog';
import { ServerAlert } from '../util/handleServerAlerts';
const { i18n } = window.SignalContext;
@@ -172,7 +172,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
getPreferredBadge: () => undefined,
hasFailedStorySends: false,
hasPendingUpdate: false,
hasCriticalIdlePrimaryDeviceAlert: false,
i18n,
isMacOS: false,
preferredWidthFromStorage: 320,
@@ -271,9 +270,6 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => {
/>
),
renderExpiredBuildDialog: props => <DialogExpiredBuild {...props} />,
renderCriticalIdlePrimaryDeviceDialog: props => (
<CriticalIdlePrimaryDeviceDialog {...props} />
),
renderUnsupportedOSDialog: props => (
<UnsupportedOSDialog
i18n={i18n}
@@ -400,7 +396,38 @@ export function InboxCriticalIdlePrimaryDeviceAlert(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
hasCriticalIdlePrimaryDeviceAlert: true,
serverAlerts: {
[ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE]: {
firstReceivedAt: Date.now(),
},
},
})}
/>
);
}
export function InboxIdlePrimaryDeviceAlert(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
serverAlerts: {
[ServerAlert.IDLE_PRIMARY_DEVICE]: {
firstReceivedAt: Date.now(),
},
},
})}
/>
);
}
export function InboxIdlePrimaryDeviceAlertNonDismissable(): JSX.Element {
return (
<LeftPaneInContainer
{...useProps({
serverAlerts: {
[ServerAlert.IDLE_PRIMARY_DEVICE]: {
firstReceivedAt: Date.now() - 10 * DAY,
dismissedAt: Date.now() - 8 * DAY,
},
},
})}
/>
);

View File

@@ -57,6 +57,8 @@ import { ContextMenu } from './ContextMenu';
import { EditState as ProfileEditorEditState } from './ProfileEditor';
import type { UnreadStats } from '../util/countUnreadStats';
import { BackupMediaDownloadProgress } from './BackupMediaDownloadProgress';
import type { ServerAlertsType } from '../util/handleServerAlerts';
import { getServerAlertDialog } from './ServerAlerts';
export type PropsType = {
backupMediaDownloadProgress: {
@@ -69,7 +71,6 @@ export type PropsType = {
otherTabsUnreadStats: UnreadStats;
hasExpiredDialog: boolean;
hasFailedStorySends: boolean;
hasCriticalIdlePrimaryDeviceAlert: boolean;
hasNetworkDialog: boolean;
hasPendingUpdate: boolean;
hasRelinkDialog: boolean;
@@ -147,6 +148,7 @@ export type PropsType = {
setComposeGroupName: (_: string) => void;
setComposeSearchTerm: (composeSearchTerm: string) => void;
setComposeSelectedRegion: (newRegion: string) => void;
serverAlerts?: ServerAlertsType;
showArchivedConversations: () => void;
showChooseGroupMembers: () => void;
showFindByUsername: () => void;
@@ -181,12 +183,6 @@ 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;
@@ -213,7 +209,6 @@ export function LeftPane({
getPreferredBadge,
hasExpiredDialog,
hasFailedStorySends,
hasCriticalIdlePrimaryDeviceAlert,
hasNetworkDialog,
hasPendingUpdate,
hasRelinkDialog,
@@ -235,7 +230,6 @@ export function LeftPane({
renderCaptchaDialog,
renderCrashReportDialog,
renderExpiredBuildDialog,
renderCriticalIdlePrimaryDeviceDialog,
renderMessageSearchResult,
renderNetworkStatus,
renderUnsupportedOSDialog,
@@ -263,6 +257,7 @@ export function LeftPane({
showConversation,
showInbox,
showUserNotFoundModal,
serverAlerts,
startComposing,
startSearch,
startSettingGroupMetadata,
@@ -608,6 +603,10 @@ export function LeftPane({
scrollBehavior = ScrollBehavior.Hard;
}
const maybeServerAlert = getServerAlertDialog(
serverAlerts,
commonDialogProps
);
// Yellow dialogs
let maybeYellowDialog: JSX.Element | undefined;
@@ -620,9 +619,8 @@ export function LeftPane({
maybeYellowDialog = renderNetworkStatus(commonDialogProps);
} else if (hasRelinkDialog) {
maybeYellowDialog = renderRelinkDialog(commonDialogProps);
} else if (hasCriticalIdlePrimaryDeviceAlert) {
maybeYellowDialog =
renderCriticalIdlePrimaryDeviceDialog(commonDialogProps);
} else if (maybeServerAlert) {
maybeYellowDialog = maybeServerAlert;
}
// Update dialog

View File

@@ -88,6 +88,13 @@ export const Warning = {
},
};
export const Info = {
args: {
type: 'info',
icon: 'error',
},
};
export const Error = {
args: {
type: 'error',
@@ -125,6 +132,14 @@ export const NarrowWarning = {
},
};
export const NarrowInfo = {
args: {
type: 'info',
icon: 'warning',
containerWidthBreakpoint: WidthBreakpoint.Narrow,
},
};
export const NarrowError = {
args: {
type: 'error',

View File

@@ -11,8 +11,8 @@ const BASE_CLASS_NAME = 'LeftPaneDialog';
const TOOLTIP_CLASS_NAME = `${BASE_CLASS_NAME}__tooltip`;
export type PropsType = {
type?: 'warning' | 'error';
icon?: 'update' | 'relink' | 'network' | 'warning' | 'error' | ReactChild;
type?: 'warning' | 'error' | 'info';
icon?: 'update' | 'relink' | 'network' | 'warning' | 'error' | JSX.Element;
title?: string;
subtitle?: string;
children?: ReactNode;
@@ -84,14 +84,6 @@ export function LeftPaneDialog({
onClose?.();
};
const iconClassName =
typeof icon === 'string'
? classNames([
`${BASE_CLASS_NAME}__icon`,
`${BASE_CLASS_NAME}__icon--${icon}`,
])
: undefined;
let action: ReactNode;
if (hasAction) {
action = (
@@ -139,11 +131,18 @@ export function LeftPaneDialog({
{action}
</>
);
const content = (
<>
<div className={`${BASE_CLASS_NAME}__container`}>
{typeof icon === 'string' ? <div className={iconClassName} /> : icon}
{icon ? (
<div className={`${BASE_CLASS_NAME}__icon-container`}>
{typeof icon === 'string' ? (
<LeftPaneDialogIcon type={icon} />
) : (
icon
)}
</div>
) : null}
{containerWidthBreakpoint !== WidthBreakpoint.Narrow && (
<div className={`${BASE_CLASS_NAME}__message`}>{message}</div>
)}
@@ -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 <div className={iconClassName} />;
}
export function LeftPaneDialogIconBackground({
type,
children,
}: {
type?: 'warning';
children: React.ReactNode;
}): JSX.Element {
return (
<div
className={`${BASE_CLASS_NAME}__icon-background ${BASE_CLASS_NAME}__icon-background--${type}`}
>
{children}
</div>
);
}

View File

@@ -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 <CriticalIdlePrimaryDeviceDialog {...dialogProps} />;
}
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 (
<WarningIdlePrimaryDeviceDialog
{...dialogProps}
handleClose={
isDismissable
? async () => {
await window.storage.put('serverAlerts', {
...alerts,
[ServerAlert.IDLE_PRIMARY_DEVICE]: {
...alert,
dismissedAt: Date.now(),
},
});
}
: undefined
}
/>
);
}
return null;
}

View File

@@ -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 (
<LeftPaneDialog
containerWidthBreakpoint={containerWidthBreakpoint}
type="info"
icon={
<LeftPaneDialogIconBackground type="warning">
<LeftPaneDialogIcon type="error" />
</LeftPaneDialogIconBackground>
}
{...(handleClose == null
? { hasXButton: false }
: {
hasXButton: true,
onClose: handleClose,
closeLabel: i18n('icu:close'),
})}
>
{i18n('icu:IdlePrimaryDevice__body')}
<div>
<a href={SUPPORT_PAGE} rel="noreferrer" target="_blank">
{i18n('icu:IdlePrimaryDevice__learnMore')}
</a>
</div>
</LeftPaneDialog>
);
}

View File

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

View File

@@ -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<ServerAlert>;
}>;
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<ServerAlert> };
}>;
export type ServerActionType = ReadonlyDeep<UpdateServerAlertsType>;
// Action Creators
function updateServerAlerts(alerts: Array<ServerAlert>): ServerActionType {
return {
type: UPDATE_SERVER_ALERTS,
payload: { alerts },
};
}
export const actions = {
updateServerAlerts,
};
// Reducer
export function getEmptyState(): ServerStateType {
return {
alerts: [],
};
}
export function reducer(
state: Readonly<ServerStateType> = getEmptyState(),
action: Readonly<ServerActionType>
): ServerStateType {
if (action.type === UPDATE_SERVER_ALERTS) {
return {
alerts: action.payload.alerts,
};
}
return state;
}

View File

@@ -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(),

View File

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

View File

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

View File

@@ -273,3 +273,8 @@ export const getBackupMediaDownloadProgress = createSelector(
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
})
);
export const getServerAlerts = createSelector(
getItems,
(state: ItemsStateType) => state.serverAlerts ?? {}
);

View File

@@ -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<ServerAlert> =>
state.server.alerts;
export const getHasCriticalIdlePrimaryDeviceAlert = createSelector(
getServerAlerts,
(alerts): boolean => {
return alerts.includes(ServerAlert.CRITICAL_IDLE_PRIMARY_DEVICE);
}
);

View File

@@ -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 <SmartMessageSearchResult id={id} />;
@@ -126,14 +124,6 @@ function renderUpdateDialog(
): JSX.Element {
return <SmartUpdateDialog {...props} />;
}
function renderCriticalIdlePrimaryDeviceDialog(
props: Readonly<{
containerWidthBreakpoint: WidthBreakpoint;
i18n: LocalizerType;
}>
): JSX.Element {
return <CriticalIdlePrimaryDeviceDialog {...props} />;
}
function renderCaptchaDialog({ onSkip }: { onSkip(): void }): JSX.Element {
return <SmartCaptchaDialog onSkip={onSkip} />;
}
@@ -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}

View File

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

View File

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

View File

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

View File

@@ -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<ServerAlert> = alerts
.map(parseServerAlertFromHeader)
.filter(v => v !== undefined);
.map(parseServerAlertsFromHeader)
.flat();
this.emit('serverAlerts', serverAlerts);
}

View File

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

View File

@@ -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<string>): void {
onReceivedAlerts(
alerts.map(parseServerAlertFromHeader).filter(v => v !== undefined)
);
onReceivedAlerts(alerts.map(parseServerAlertsFromHeader).flat());
},
};
return connectLibsignal(

View File

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

View File

@@ -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<ServerAlert>): 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<ServerAlert> {
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<ServerAlert>
): Promise<void> {
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;
}