diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 7c92604aae..59e13f5dec 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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. Learn more", "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." diff --git a/stylesheets/components/LeftPaneDialog.scss b/stylesheets/components/LeftPaneDialog.scss index 1163097886..9c59a0ce6c 100644 --- a/stylesheets/components/LeftPaneDialog.scss +++ b/stylesheets/components/LeftPaneDialog.scss @@ -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; diff --git a/ts/background.ts b/ts/background.ts index 361643ab84..4f821aefaf 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -1930,7 +1930,7 @@ export async function startApp(): Promise { log.info('afterAuthSocketConnect/afterEveryAuthConnect'); strictAssert(server, 'afterEveryAuthConnect: server'); - handleServerAlerts(server.getServerAlerts()); + drop(handleServerAlerts(server.getServerAlerts())); strictAssert(challengeHandler, 'afterEveryAuthConnect: challengeHandler'); drop(challengeHandler.onOnline()); diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index 4febb3e275..7f8b08e2e8 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -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 => , - renderCriticalIdlePrimaryDeviceDialog: props => ( - - ), renderUnsupportedOSDialog: props => ( + ); +} +export function InboxIdlePrimaryDeviceAlert(): JSX.Element { + return ( + + ); +} +export function InboxIdlePrimaryDeviceAlertNonDismissable(): JSX.Element { + return ( + ); diff --git a/ts/components/LeftPane.tsx b/ts/components/LeftPane.tsx index 26146bfaef..3b7f40ba17 100644 --- a/ts/components/LeftPane.tsx +++ b/ts/components/LeftPane.tsx @@ -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 diff --git a/ts/components/LeftPaneDialog.stories.tsx b/ts/components/LeftPaneDialog.stories.tsx index b4e88bbe7a..607490f3a7 100644 --- a/ts/components/LeftPaneDialog.stories.tsx +++ b/ts/components/LeftPaneDialog.stories.tsx @@ -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', diff --git a/ts/components/LeftPaneDialog.tsx b/ts/components/LeftPaneDialog.tsx index b5abc0fd53..411d958f27 100644 --- a/ts/components/LeftPaneDialog.tsx +++ b/ts/components/LeftPaneDialog.tsx @@ -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 = ( <>
- {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; }