+ {i18n('icu:Preferences__button--manage')}
+
+ }
+ />
+
+ ) : (
+
+
+ {i18n('icu:Preferences--signal-backups')}{' '}
+
+
+
+
+ }
+ right={null}
+ />
+
+ )}
+
+
+
- {i18n('icu:Preferences--backup-size__label')}{' '}
-
- {formatFileSize(
- (cloudBackupStatus.mediaSize ?? 0) +
- (cloudBackupStatus.protoSize ?? 0)
- )}
+ {i18n('icu:Preferences__local-backups')}{' '}
+
+ {isLocalBackupsSetup
+ ? null
+ : i18n('icu:Preferences--local-backups-off-description')}
-
-
- ) : null}
+ }
+ right={
+
+ }
+ />
+
>
);
}
+
function getSubscriptionDetails({
i18n,
subscriptionStatus,
@@ -128,7 +231,8 @@ function getSubscriptionDetails({
return null;
}
-export function getBackupsSubscriptionSummary({
+
+export function renderBackupsSubscriptionDetails({
subscriptionStatus,
i18n,
locale,
@@ -212,3 +316,111 @@ export function getBackupsSubscriptionSummary({
throw missingCaseError(status);
}
}
+
+export function renderBackupsSubscriptionSummary({
+ subscriptionStatus,
+ i18n,
+ locale,
+}: {
+ locale: string;
+ subscriptionStatus?: BackupsSubscriptionType;
+ i18n: LocalizerType;
+}): JSX.Element | null {
+ if (!subscriptionStatus) {
+ return null;
+ }
+
+ const { status } = subscriptionStatus;
+ switch (status) {
+ case 'active':
+ case 'pending-cancellation':
+ return (
+
+
+
+ {i18n('icu:Preferences--backup-media-plan__description')}
+
+
+ {getSubscriptionDetails({ i18n, locale, subscriptionStatus })}
+
+
+
+ );
+ case 'free':
+ return (
+
+
+
+ {i18n('icu:Preferences--backup-messages-plan__description', {
+ mediaDayCount:
+ subscriptionStatus.mediaIncludedInBackupDurationDays,
+ })}
+
+
+ {i18n('icu:Preferences--backup-messages-plan__cost-description')}
+
+
+
+ );
+ case 'not-found':
+ case 'expired':
+ return (
+
+
+ {i18n('icu:Preferences--backup-plan-not-found__description')}
+
+
+ );
+ default:
+ throw missingCaseError(status);
+ }
+}
+
+function BackupsDetailsPage({
+ cloudBackupStatus,
+ backupSubscriptionStatus,
+ i18n,
+ locale,
+}: {
+ cloudBackupStatus?: BackupStatusType;
+ backupSubscriptionStatus?: BackupsSubscriptionType;
+ i18n: LocalizerType;
+ locale: string;
+}): JSX.Element {
+ return (
+ <>
+
+ {renderBackupsSubscriptionDetails({
+ subscriptionStatus: backupSubscriptionStatus,
+ i18n,
+ locale,
+ })}
+
+
+ {cloudBackupStatus ? (
+
+ {cloudBackupStatus.createdAt ? (
+
+
+
+ {/* TODO (DESKTOP-8509) */}
+ {i18n('icu:Preferences--backup-created-by-phone')}
+
+ {formatTimestamp(cloudBackupStatus.createdAt, {
+ dateStyle: 'medium',
+ timeStyle: 'short',
+ })}
+
+
+ ) : null}
+
+ ) : null}
+ >
+ );
+}
diff --git a/ts/components/PreferencesLocalBackups.tsx b/ts/components/PreferencesLocalBackups.tsx
new file mode 100644
index 0000000000..7adcb4168c
--- /dev/null
+++ b/ts/components/PreferencesLocalBackups.tsx
@@ -0,0 +1,400 @@
+// Copyright 2025 Signal Messenger, LLC
+// SPDX-License-Identifier: AGPL-3.0-only
+
+import type { ChangeEvent } from 'react';
+import React, {
+ useCallback,
+ useEffect,
+ useMemo,
+ useState,
+ useRef,
+} from 'react';
+import { noop } from 'lodash';
+import type { LocalizerType } from '../types/I18N';
+import { SettingsControl as Control, SettingsRow } from './PreferencesUtil';
+import { Button, ButtonSize, ButtonVariant } from './Button';
+import { SIGNAL_BACKUPS_LEARN_MORE_URL } from './PreferencesBackups';
+import { I18n } from './I18n';
+import type { PreferencesBackupPage } from '../types/PreferencesBackupPage';
+import { Page } from './Preferences';
+import { ToastType } from '../types/Toast';
+import type { ShowToastAction } from '../state/ducks/toast';
+import { Modal } from './Modal';
+import { strictAssert } from '../util/assert';
+
+export function PreferencesLocalBackups({
+ accountEntropyPool,
+ backupKeyViewed,
+ i18n,
+ localBackupFolder,
+ onBackupKeyViewedChange,
+ page,
+ pickLocalBackupFolder,
+ setPage,
+ showToast,
+}: {
+ accountEntropyPool: string | undefined;
+ backupKeyViewed: boolean;
+ i18n: LocalizerType;
+ localBackupFolder: string | undefined;
+ onBackupKeyViewedChange: (keyViewed: boolean) => void;
+ page: PreferencesBackupPage;
+ pickLocalBackupFolder: () => Promise
;
+ setPage: (page: PreferencesBackupPage) => void;
+ showToast: ShowToastAction;
+}): JSX.Element {
+ if (!localBackupFolder) {
+ return (
+
+ );
+ }
+
+ const isReferencingBackupKey = page === Page.LocalBackupsKeyReference;
+ if (!backupKeyViewed || isReferencingBackupKey) {
+ strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
+
+ return (
+ {
+ if (backupKeyViewed) {
+ setPage(Page.LocalBackups);
+ } else {
+ onBackupKeyViewedChange(true);
+ }
+ }}
+ showToast={showToast}
+ />
+ );
+ }
+
+ const learnMoreLink = (parts: Array) => (
+
+ {parts}
+
+ );
+
+ return (
+ <>
+
+
+ {i18n('icu:Preferences__local-backups-section__description')}
+
+
+
+
+ {i18n('icu:Preferences__local-backups-folder')}
+
+ {localBackupFolder}
+
+
+ }
+ right={
+
+ }
+ />
+
+ {i18n('icu:Preferences__backup-key')}
+
+ {i18n('icu:Preferences__backup-key-description')}
+
+
+ }
+ right={
+
+ }
+ />
+
+
+
+
+ >
+ );
+}
+
+function LocalBackupsSetupFolderPicker({
+ i18n,
+ pickLocalBackupFolder,
+}: {
+ i18n: LocalizerType;
+ pickLocalBackupFolder: () => Promise;
+}): JSX.Element {
+ return (
+
+
+
+
+
+ {i18n('icu:Preferences--local-backups-setup-folder-description')}
+
+
+
+
+ );
+}
+
+type BackupKeyStep = 'view' | 'confirm' | 'caution' | 'reference';
+
+function LocalBackupsBackupKeyViewer({
+ accountEntropyPool,
+ i18n,
+ isReferencing,
+ onBackupKeyViewed,
+ showToast,
+}: {
+ accountEntropyPool: string;
+ i18n: LocalizerType;
+ isReferencing: boolean;
+ onBackupKeyViewed: () => void;
+ showToast: ShowToastAction;
+}): JSX.Element {
+ const [isBackupKeyConfirmed, setIsBackupKeyConfirmed] =
+ useState(false);
+ const [step, setStep] = useState(
+ isReferencing ? 'reference' : 'view'
+ );
+ const isStepViewOrReference = step === 'view' || step === 'reference';
+
+ const backupKey = useMemo(() => {
+ return accountEntropyPool
+ .replace(/\s/g, '')
+ .replace(/.{4}(?=.)/g, '$& ')
+ .toUpperCase();
+ }, [accountEntropyPool]);
+
+ const onCopyBackupKey = useCallback(
+ async function handleCopyBackupKey(e: React.MouseEvent) {
+ e.preventDefault();
+ await window.navigator.clipboard.writeText(backupKey);
+ showToast({ toastType: ToastType.CopiedBackupKey });
+ },
+ [backupKey, showToast]
+ );
+
+ const learnMoreLink = (parts: Array) => (
+
+ {parts}
+
+ );
+
+ let title: string;
+ let description: JSX.Element | string;
+ let footerLeft: JSX.Element | undefined;
+ let footerRight: JSX.Element;
+ if (isStepViewOrReference) {
+ title = i18n('icu:Preferences--local-backups-record-backup-key');
+ description = (
+
+ );
+ if (step === 'view') {
+ footerRight = (
+
+ );
+ } else {
+ footerRight = (
+
+ );
+ }
+ } else {
+ title = i18n('icu:Preferences--local-backups-confirm-backup-key');
+ description = i18n(
+ 'icu:Preferences--local-backups-confirm-backup-key-description'
+ );
+ footerLeft = (
+
+ );
+ footerRight = (
+
+ );
+ }
+
+ return (
+
+ {step === 'caution' && (
+
+ {i18n(
+ 'icu:Preferences__local-backups-confirm-key-modal-continue'
+ )}
+
+ }
+ onClose={() => setStep('confirm')}
+ padded={false}
+ >
+
+
+
+ {i18n('icu:Preferences__local-backups-confirm-key-modal-body')}
+
+
+ )}
+
+
+
+
+
+ {description}
+
+
+
+
+
+ setIsBackupKeyConfirmed(isValid)}
+ isStepViewOrReference={isStepViewOrReference}
+ />
+
+ {isStepViewOrReference && (
+
+
+
+ )}
+
+
+
+ {footerLeft}
+
+
+ {footerRight}
+
+
+
+ );
+}
+
+function LocalBackupsBackupKeyTextarea({
+ backupKey,
+ i18n,
+ onValidate,
+ isStepViewOrReference,
+}: {
+ backupKey: string;
+ i18n: LocalizerType;
+ onValidate: (isValid: boolean) => void;
+ isStepViewOrReference: boolean;
+}): JSX.Element {
+ const backupKeyTextareaRef = useRef(null);
+ const [backupKeyInput, setBackupKeyInput] = useState('');
+
+ useEffect(() => {
+ if (backupKeyTextareaRef.current) {
+ backupKeyTextareaRef.current.focus();
+ }
+ }, [backupKeyTextareaRef, isStepViewOrReference]);
+
+ const backupKeyNoSpaces = React.useMemo(() => {
+ return backupKey.replace(/\s/g, '');
+ }, [backupKey]);
+
+ const handleTextareaChange = useCallback(
+ (ev: ChangeEvent) => {
+ const { value } = ev.target;
+ const valueUppercaseNoSpaces = value.replace(/\s/g, '').toUpperCase();
+ const valueForUI = valueUppercaseNoSpaces.replace(/.{4}(?=.)/g, '$& ');
+ setBackupKeyInput(valueForUI);
+ onValidate(valueUppercaseNoSpaces === backupKeyNoSpaces);
+ },
+ [backupKeyNoSpaces, onValidate]
+ );
+
+ return (
+
+ );
+}
diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx
index 545de51559..5902d4d32f 100644
--- a/ts/components/ToastManager.stories.tsx
+++ b/ts/components/ToastManager.stories.tsx
@@ -78,6 +78,8 @@ function getToast(toastType: ToastType): AnyToast {
};
case ToastType.ConversationUnarchived:
return { toastType: ToastType.ConversationUnarchived };
+ case ToastType.CopiedBackupKey:
+ return { toastType: ToastType.CopiedBackupKey };
case ToastType.CopiedCallLink:
return { toastType: ToastType.CopiedCallLink };
case ToastType.CopiedUsername:
diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx
index 32456ec733..e9fd5ad69d 100644
--- a/ts/components/ToastManager.tsx
+++ b/ts/components/ToastManager.tsx
@@ -225,6 +225,14 @@ export function renderToast({
);
}
+ if (toastType === ToastType.CopiedBackupKey) {
+ return (
+
+ {i18n('icu:Preferences__local-backups-copied-key')}
+
+ );
+ }
+
if (toastType === ToastType.CopiedCallLink) {
return (
diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts
index c1fafd5b0d..db9e0cb380 100644
--- a/ts/services/backups/index.ts
+++ b/ts/services/backups/index.ts
@@ -1115,6 +1115,18 @@ export class BackupsService {
getCachedCloudBackupStatus(): BackupStatusType | undefined {
return window.storage.get('cloudBackupStatus');
}
+
+ async pickLocalBackupFolder(): Promise {
+ const { canceled, dirPath: snapshotDir } = await ipcRenderer.invoke(
+ 'show-open-folder-dialog'
+ );
+ if (canceled || !snapshotDir) {
+ return;
+ }
+
+ drop(window.storage.put('localBackupFolder', snapshotDir));
+ return snapshotDir;
+ }
}
export const backupsService = new BackupsService();
diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx
index 5d582ad944..c4ce07f63a 100644
--- a/ts/state/smart/Preferences.tsx
+++ b/ts/state/smart/Preferences.tsx
@@ -69,6 +69,7 @@ import { SmartToastManager } from './ToastManager';
import { useToastActions } from '../ducks/toast';
import { DataReader } from '../../sql/Client';
import { deleteAllMyStories } from '../../util/deleteAllMyStories';
+import { isLocalBackupsEnabledForRedux } from '../../util/isLocalBackupsEnabled';
import type { StorageAccessType, ZoomFactorType } from '../../types/Storage';
import type { ThemeType } from '../../util/preload';
@@ -190,6 +191,8 @@ export function SmartPreferences(): JSX.Element | null {
const validateBackup = () => backupsService._internalValidate();
const exportLocalBackup = () => backupsService._internalExportLocalBackup();
+ const pickLocalBackupFolder = () => backupsService.pickLocalBackupFolder();
+
const doDeleteAllData = () => renderClearingDataView();
const refreshCloudBackupStatus =
window.Signal.Services.backups.throttledFetchCloudBackupStatus;
@@ -457,7 +460,8 @@ export function SmartPreferences(): JSX.Element | null {
// Simple, one-way items
- const { backupSubscriptionStatus, cloudBackupStatus } = items;
+ const { backupSubscriptionStatus, cloudBackupStatus, localBackupFolder } =
+ items;
const defaultConversationColor =
items.defaultConversationColor || DEFAULT_CONVERSATION_COLOR;
const hasLinkPreviews = items.linkPreviews ?? false;
@@ -476,6 +480,9 @@ export function SmartPreferences(): JSX.Element | null {
const backupFeatureEnabled = isBackupFeatureEnabledForRedux(
items.remoteConfig
);
+ const backupLocalBackupsEnabled = isLocalBackupsEnabledForRedux(
+ items.remoteConfig
+ );
// Two-way items
@@ -498,6 +505,11 @@ export function SmartPreferences(): JSX.Element | null {
'auto-download-attachment',
DEFAULT_AUTO_DOWNLOAD_ATTACHMENT
);
+ const [backupKeyViewed, onBackupKeyViewedChange] = createItemsAccess(
+ 'backupKeyViewed',
+ false
+ );
+
const [hasAudioNotifications, onAudioNotificationsChange] = createItemsAccess(
'audio-notification',
false
@@ -649,9 +661,12 @@ export function SmartPreferences(): JSX.Element | null {
});
};
+ const accountEntropyPool = window.storage.get('accountEntropyPool');
+
return (
| undefined
+): boolean {
+ if (isStagingServer() || isTestOrMockEnvironment()) {
+ return true;
+ }
+
+ if (config?.['desktop.internalUser']?.enabled) {
+ return true;
+ }
+
+ const version = window.getVersion?.();
+ if (version != null) {
+ return isNightly(version);
+ }
+
+ return false;
+}
diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json
index ebbdc00022..372f2d5a9c 100644
--- a/ts/util/lint/exceptions.json
+++ b/ts/util/lint/exceptions.json
@@ -2232,5 +2232,13 @@
"line": " message.innerHTML = window.i18n('icu:optimizingApplication');",
"reasonCategory": "usageTrusted",
"updated": "2021-09-17T21:02:59.414Z"
+ },
+ {
+ "rule": "React-useRef",
+ "path": "ts/components/PreferencesLocalBackups.tsx",
+ "line": " const backupKeyTextareaRef = useRef(null);",
+ "reasonCategory": "usageTrusted",
+ "updated": "2025-05-30T22:48:14.420Z",
+ "reasonDetail": "For focusing the settings backup key viewer textarea"
}
]