Support idle primary device warning alert from server
This commit is contained in:
@@ -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."
|
||||
|
@@ -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;
|
||||
|
@@ -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());
|
||||
|
@@ -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,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
);
|
||||
|
@@ -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
|
||||
|
@@ -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',
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
|
63
ts/components/ServerAlerts.tsx
Normal file
63
ts/components/ServerAlerts.tsx
Normal 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;
|
||||
}
|
52
ts/components/WarningIdlePrimaryDeviceDialog.tsx
Normal file
52
ts/components/WarningIdlePrimaryDeviceDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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,
|
||||
|
@@ -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;
|
||||
}
|
@@ -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(),
|
||||
|
@@ -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(
|
||||
|
@@ -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,
|
||||
|
@@ -273,3 +273,8 @@ export const getBackupMediaDownloadProgress = createSelector(
|
||||
downloadBannerDismissed: state.backupMediaDownloadBannerDismissed ?? false,
|
||||
})
|
||||
);
|
||||
|
||||
export const getServerAlerts = createSelector(
|
||||
getItems,
|
||||
(state: ItemsStateType) => state.serverAlerts ?? {}
|
||||
);
|
||||
|
@@ -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);
|
||||
}
|
||||
);
|
@@ -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}
|
||||
|
@@ -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;
|
||||
|
45
ts/test-both/util/serverAlerts_test.ts
Normal file
45
ts/test-both/util/serverAlerts_test.ts
Normal 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
|
||||
);
|
||||
});
|
||||
});
|
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
@@ -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);
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -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(
|
||||
|
2
ts/types/Storage.d.ts
vendored
2
ts/types/Storage.d.ts
vendored
@@ -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;
|
||||
|
@@ -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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user