From f786f40e3cbee570ac6dab41d9699fb5e5a823c6 Mon Sep 17 00:00:00 2001 From: automated-signal <37887102+automated-signal@users.noreply.github.com> Date: Tue, 20 Jan 2026 14:52:57 -0600 Subject: [PATCH] Update local backup export UI Co-authored-by: trevor-signal <131492920+trevor-signal@users.noreply.github.com> --- _locales/en/messages.json | 36 +++ ...-delete-sync.svg => desktop-and-phone.svg} | 0 ts/CI.preload.ts | 2 +- ts/components/GlobalModalContainer.dom.tsx | 10 + .../LocalBackupExportWorkflow.dom.stories.tsx | 146 +++++++++ .../LocalBackupExportWorkflow.dom.tsx | 269 +++++++++++++++++ ts/components/LocalDeleteWarningModal.dom.tsx | 2 +- .../PlaintextExportWorkflow.dom.stories.tsx | 14 +- ts/components/PlaintextExportWorkflow.dom.tsx | 14 +- ts/components/Preferences.dom.stories.tsx | 32 +- ts/components/Preferences.dom.tsx | 10 +- ts/components/PreferencesBackups.dom.tsx | 7 + ts/components/PreferencesInternal.dom.tsx | 55 ---- ts/components/PreferencesLocalBackups.dom.tsx | 36 +++ ts/services/backups/index.preload.ts | 181 +++++------ ts/state/ducks/backups.preload.ts | 283 ++++++++++++++++-- ts/state/selectors/backups.std.ts | 39 ++- .../smart/GlobalModalContainer.preload.tsx | 17 +- .../LocalBackupExportWorkflow.preload.tsx | 59 ++++ .../smart/PlaintextExportWorkflow.preload.tsx | 4 +- ts/state/smart/Preferences.preload.tsx | 8 +- .../{Backups.std.ts => LocalExport.std.ts} | 139 ++++++--- ts/types/Storage.d.ts | 2 + 23 files changed, 1085 insertions(+), 280 deletions(-) rename images/{local-delete-sync.svg => desktop-and-phone.svg} (100%) create mode 100644 ts/components/LocalBackupExportWorkflow.dom.stories.tsx create mode 100644 ts/components/LocalBackupExportWorkflow.dom.tsx create mode 100644 ts/state/smart/LocalBackupExportWorkflow.preload.tsx rename ts/types/{Backups.std.ts => LocalExport.std.ts} (59%) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 2840b419ad..3c0601f268 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -7480,6 +7480,30 @@ "messageformat": "Your chat history can’t be exported because Signal doesn’t have permission to write files to disk. Try changing your disk's permissions or updating your system settings and then export again.", "description": "Detail text in dialog shown when we attempted to save a file during export but got a permissions error" }, + "icu:LocalBackupExport--ProgressDialog--Header": { + "messageformat": "Exporting backup", + "description": "Title of a progress dialog that shows while exporting a local backup" + }, + "icu:LocalBackupExport--CompleteDialog--Header": { + "messageformat": "Backup export complete", + "description": "Title of the dialog shown once the export is complete" + }, + "icu:LocalBackupExport--CompleteDialog--RestoreInstructionsHeader": { + "messageformat": "To restore this backup:", + "description": "Shown above a list of three bullet-point instructions that show users how to restore their chat history from this backup on their phone" + }, + "icu:LocalBackupExport--CompleteDialog--RestoreInstructionsTransfer": { + "messageformat": "Transfer your backup folder to your phone's storage", + "description": "First step in instructions for users on how to restore their local chat history backup onto their phone" + }, + "icu:LocalBackupExport--CompleteDialog--RestoreInstructionsInstall": { + "messageformat": "Install a new copy of Signal on your phone", + "description": "Second step in instructions for users on how to restore their chat history backup onto their phone" + }, + "icu:LocalBackupExport--CompleteDialog--RestoreInstructionsRestore": { + "messageformat": "Tap “Restore or Transfer” and choose “On Device Backup”", + "description": "Third step in instructions for users on how to restore their chat history backup onto their phone" + }, "icu:NotificationProfile--moon-icon": { "messageformat": "Moon icon", "description": "Screenreader description for the moon icon used to signify notification profiles" @@ -7956,6 +7980,18 @@ "messageformat": "See key again", "description": "Link text to return to the previous backup key page, shown when confirming the backup key for local message backups." }, + "icu:Preferences__local-backups-last-backup": { + "messageformat": "Last backup", + "description": "Label for the last backup date in local on-device backups settings." + }, + "icu:Preferences__local-backups-last-backup-never": { + "messageformat": "Never", + "description": "Text shown when no local backup has been created yet." + }, + "icu:Preferences__local-backups-backup-now": { + "messageformat": "Back up now", + "description": "Button to trigger a local backup immediately." + }, "icu:Preferences__local-backups-folder": { "messageformat": "Backup folder", "description": "Label for current folder in which local on-device backups are stored." diff --git a/images/local-delete-sync.svg b/images/desktop-and-phone.svg similarity index 100% rename from images/local-delete-sync.svg rename to images/desktop-and-phone.svg diff --git a/ts/CI.preload.ts b/ts/CI.preload.ts index 0950c1fc6e..25009f7893 100644 --- a/ts/CI.preload.ts +++ b/ts/CI.preload.ts @@ -204,7 +204,7 @@ export function getCI({ } async function exportLocalBackup(backupsBaseDir: string): Promise { - const { snapshotDir } = await backupsService.exportLocalEncryptedBackup({ + const { snapshotDir } = await backupsService.exportLocalBackup({ backupsBaseDir, onProgress: () => null, abortSignal: new AbortController().signal, diff --git a/ts/components/GlobalModalContainer.dom.tsx b/ts/components/GlobalModalContainer.dom.tsx index 4e9083be2b..d910fde21a 100644 --- a/ts/components/GlobalModalContainer.dom.tsx +++ b/ts/components/GlobalModalContainer.dom.tsx @@ -165,6 +165,9 @@ export type PropsType = { // PlaintextExportWorkflow shouldShowPlaintextExportWorkflow: boolean; renderPlaintextExportWorkflow: () => React.JSX.Element; + // LocalBackupExportWorkflow + shouldShowLocalBackupExportWorkflow: boolean; + renderLocalBackupExportWorkflow: () => React.JSX.Element; }; export function GlobalModalContainer({ @@ -270,6 +273,9 @@ export function GlobalModalContainer({ // PlaintextExportWorkflow shouldShowPlaintextExportWorkflow, renderPlaintextExportWorkflow, + // LocalBackupExportWorkflow + shouldShowLocalBackupExportWorkflow, + renderLocalBackupExportWorkflow, }: PropsType): React.JSX.Element | null { // We want the following dialogs to show in this order: // 0. Stateful multi-modal workflows @@ -282,6 +288,10 @@ export function GlobalModalContainer({ return renderPlaintextExportWorkflow(); } + if (shouldShowLocalBackupExportWorkflow) { + return renderLocalBackupExportWorkflow(); + } + // Errors if (errorModalProps) { return renderErrorModal(errorModalProps); diff --git a/ts/components/LocalBackupExportWorkflow.dom.stories.tsx b/ts/components/LocalBackupExportWorkflow.dom.stories.tsx new file mode 100644 index 0000000000..7a2b5e95e8 --- /dev/null +++ b/ts/components/LocalBackupExportWorkflow.dom.stories.tsx @@ -0,0 +1,146 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { LocalBackupExportWorkflow } from './LocalBackupExportWorkflow.dom.js'; +import { + LocalExportErrors, + LocalBackupExportSteps, +} from '../types/LocalExport.std.js'; + +import type { PropsType } from './LocalBackupExportWorkflow.dom.js'; +import type { ComponentMeta } from '../storybook/types.std.js'; + +const { i18n } = window.SignalContext; + +export default { + title: 'Components/LocalBackupExportWorkflow', + component: LocalBackupExportWorkflow, + args: { + cancelWorkflow: action('cancelWorkflow'), + clearWorkflow: action('clearWorkflow'), + i18n, + openFileInFolder: action('openFileInFolder'), + osName: undefined, + workflow: { + step: LocalBackupExportSteps.ExportingMessages, + localBackupFolder: 'backups', + abortController: new AbortController(), + }, + }, +} satisfies ComponentMeta; + +export function ExportingMessages(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ExportingAttachments(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function CompleteMac(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function CompleteLinux(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ErrorGeneric(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ErrorNotEnoughStorage(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ErrorRanOutOfStorage(args: PropsType): React.JSX.Element { + return ( + + ); +} + +export function ErrorStoragePermissions(args: PropsType): React.JSX.Element { + return ( + + ); +} diff --git a/ts/components/LocalBackupExportWorkflow.dom.tsx b/ts/components/LocalBackupExportWorkflow.dom.tsx new file mode 100644 index 0000000000..08a521829b --- /dev/null +++ b/ts/components/LocalBackupExportWorkflow.dom.tsx @@ -0,0 +1,269 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { type ReactNode } from 'react'; + +import { + LocalExportErrors, + LocalBackupExportSteps, +} from '../types/LocalExport.std.js'; +import { AxoDialog } from '../axo/AxoDialog.dom.js'; +import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js'; + +import type { LocalBackupExportWorkflowType } from '../types/LocalExport.std.js'; +import type { LocalizerType } from '../types/I18N.std.js'; +import { formatFileSize } from '../util/formatFileSize.std.js'; +import { ProgressBar } from './ProgressBar.dom.js'; +import { missingCaseError } from '../util/missingCaseError.std.js'; +import { tw } from '../axo/tw.dom.js'; +import { AxoSymbol } from '../axo/AxoSymbol.dom.js'; +import type { AxoSymbolIconName } from '../axo/_internal/AxoSymbolDefs.generated.std.js'; + +export type PropsType = { + cancelWorkflow: () => void; + clearWorkflow: () => void; + i18n: LocalizerType; + openFileInFolder: (path: string) => void; + osName: 'linux' | 'macos' | 'windows' | undefined; + workflow: LocalBackupExportWorkflowType; +}; + +export function LocalBackupExportWorkflow({ + cancelWorkflow, + clearWorkflow, + i18n, + openFileInFolder, + osName, + workflow, +}: PropsType): React.JSX.Element { + const { step } = workflow; + + if ( + step === LocalBackupExportSteps.ExportingMessages || + step === LocalBackupExportSteps.ExportingAttachments + ) { + const progress = + step === LocalBackupExportSteps.ExportingAttachments + ? workflow.progress + : undefined; + + let progressElements; + if (progress) { + const fractionComplete = + progress.totalBytes > 0 + ? progress.currentBytes / progress.totalBytes + : 0; + + progressElements = ( + <> +
+ +
+
+ {i18n('icu:PlaintextExport--ProgressDialog--Progress', { + currentBytes: formatFileSize(progress.currentBytes), + totalBytes: formatFileSize(progress.totalBytes), + percentage: fractionComplete, + })} +
+ + ); + } else { + progressElements = ( +
+ +
+ ); + } + + return ( + + + + +
+ {i18n('icu:LocalBackupExport--ProgressDialog--Header')} +
+
+
+ +
+ {progressElements} +
+ {i18n('icu:PlaintextExport--ProgressDialog--TimeWarning')} +
+
+
+ +
+ + {i18n('icu:cancel')} + +
+
+
+
+ ); + } + + if (step === LocalBackupExportSteps.Complete) { + let showInFolderText = i18n( + 'icu:PlaintextExport--CompleteDialog--ShowFiles--Windows' + ); + if (osName === 'macos') { + showInFolderText = i18n( + 'icu:PlaintextExport--CompleteDialog--ShowFiles--Mac' + ); + } else if (osName === 'linux') { + showInFolderText = i18n( + 'icu:PlaintextExport--CompleteDialog--ShowFiles--Linux' + ); + } + + return ( + + + +
+ + +
+ {i18n('icu:LocalBackupExport--CompleteDialog--Header')} +
+
+
+ +
+ {i18n( + 'icu:LocalBackupExport--CompleteDialog--RestoreInstructionsHeader' + )} +
    + + + +
+
+
+
+ + { + openFileInFolder(workflow.localBackupFolder); + clearWorkflow(); + }} + > + {showInFolderText} + + + {i18n('icu:ok')} + + +
+
+ ); + } + + if (step === LocalBackupExportSteps.Error) { + const { type } = workflow.errorDetails; + let title; + let detail; + + if (type === LocalExportErrors.General) { + title = i18n('icu:PlaintextExport--Error--General--Title'); + detail = i18n('icu:PlaintextExport--Error--General--Description'); + } else if (type === LocalExportErrors.NotEnoughStorage) { + title = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Title'); + detail = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Detail', { + bytes: formatFileSize(workflow.errorDetails.bytesNeeded), + }); + } else if (type === LocalExportErrors.RanOutOfStorage) { + title = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Title'); + detail = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Detail', { + bytes: formatFileSize(workflow.errorDetails.bytesNeeded), + }); + } else if (type === LocalExportErrors.StoragePermissions) { + title = i18n('icu:PlaintextExport--Error--DiskPermssions--Title'); + detail = i18n('icu:PlaintextExport--Error--DiskPermssions--Detail'); + } else { + throw missingCaseError(type); + } + + return ( + + + + {title} + {detail} + + + + {i18n('icu:ok')} + + + + + ); + } + + throw missingCaseError(step); +} + +function ListItemWithIcon({ + iconName, + content, +}: { + iconName: AxoSymbolIconName; + content: ReactNode; +}): ReactNode { + return ( +
  • +
    + +
    +
    {content}
    +
  • + ); +} diff --git a/ts/components/LocalDeleteWarningModal.dom.tsx b/ts/components/LocalDeleteWarningModal.dom.tsx index 2f4d57c916..66f7ec18bb 100644 --- a/ts/components/LocalDeleteWarningModal.dom.tsx +++ b/ts/components/LocalDeleteWarningModal.dom.tsx @@ -27,7 +27,7 @@ export function LocalDeleteWarningModal({
    @@ -79,7 +78,6 @@ export function ExportingAttachments(args: PropsType): React.JSX.Element { workflow={{ step: PlaintextExportSteps.ExportingAttachments, abortController: new AbortController(), - exportInBackground: false, exportPath: '/somewhere', progress: { totalBytes: 1000000, @@ -123,7 +121,7 @@ export function ErrorGeneric(args: PropsType): React.JSX.Element { workflow={{ step: PlaintextExportSteps.Error, errorDetails: { - type: PlaintextExportErrors.General, + type: LocalExportErrors.General, }, }} /> @@ -137,7 +135,7 @@ export function ErrorNotEnoughStorage(args: PropsType): React.JSX.Element { workflow={{ step: PlaintextExportSteps.Error, errorDetails: { - type: PlaintextExportErrors.NotEnoughStorage, + type: LocalExportErrors.NotEnoughStorage, bytesNeeded: 12000000, }, }} @@ -152,7 +150,7 @@ export function ErrorRanOutOfStorage(args: PropsType): React.JSX.Element { workflow={{ step: PlaintextExportSteps.Error, errorDetails: { - type: PlaintextExportErrors.RanOutOfStorage, + type: LocalExportErrors.RanOutOfStorage, bytesNeeded: 12000000, }, }} @@ -167,7 +165,7 @@ export function ErrorStoragePermissions(args: PropsType): React.JSX.Element { workflow={{ step: PlaintextExportSteps.Error, errorDetails: { - type: PlaintextExportErrors.StoragePermissions, + type: LocalExportErrors.StoragePermissions, }, }} /> diff --git a/ts/components/PlaintextExportWorkflow.dom.tsx b/ts/components/PlaintextExportWorkflow.dom.tsx index 5a9eb31e8c..b6422594e3 100644 --- a/ts/components/PlaintextExportWorkflow.dom.tsx +++ b/ts/components/PlaintextExportWorkflow.dom.tsx @@ -4,13 +4,13 @@ import React from 'react'; import { - PlaintextExportErrors, + LocalExportErrors, PlaintextExportSteps, -} from '../types/Backups.std.js'; +} from '../types/LocalExport.std.js'; import { AxoDialog } from '../axo/AxoDialog.dom.js'; import { AxoAlertDialog } from '../axo/AxoAlertDialog.dom.js'; -import type { PlaintextExportWorkflowType } from '../types/Backups.std.js'; +import type { PlaintextExportWorkflowType } from '../types/LocalExport.std.js'; import type { LocalizerType } from '../types/I18N.std.js'; import { AxoCheckbox } from '../axo/AxoCheckbox.dom.js'; import { formatFileSize } from '../util/formatFileSize.std.js'; @@ -265,20 +265,20 @@ export function PlaintextExportWorkflow({ let title; let detail; - if (type === PlaintextExportErrors.General) { + if (type === LocalExportErrors.General) { title = i18n('icu:PlaintextExport--Error--General--Title'); detail = i18n('icu:PlaintextExport--Error--General--Description'); - } else if (type === PlaintextExportErrors.NotEnoughStorage) { + } else if (type === LocalExportErrors.NotEnoughStorage) { title = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Title'); detail = i18n('icu:PlaintextExport--Error--NotEnoughStorage--Detail', { bytes: formatFileSize(workflow.errorDetails.bytesNeeded), }); - } else if (type === PlaintextExportErrors.RanOutOfStorage) { + } else if (type === LocalExportErrors.RanOutOfStorage) { title = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Title'); detail = i18n('icu:PlaintextExport--Error--RanOutOfStorage--Detail', { bytes: formatFileSize(workflow.errorDetails.bytesNeeded), }); - } else if (type === PlaintextExportErrors.StoragePermissions) { + } else if (type === LocalExportErrors.StoragePermissions) { title = i18n('icu:PlaintextExport--Error--DiskPermssions--Title'); detail = i18n('icu:PlaintextExport--Error--DiskPermssions--Detail'); } else { diff --git a/ts/components/Preferences.dom.stories.tsx b/ts/components/Preferences.dom.stories.tsx index 56745d60b4..06270bdd9d 100644 --- a/ts/components/Preferences.dom.stories.tsx +++ b/ts/components/Preferences.dom.stories.tsx @@ -58,10 +58,7 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre import { CurrentChatFolders } from '../types/CurrentChatFolders.std.js'; import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js'; import type { NotificationProfileIdString } from '../types/NotificationProfile.std.js'; -import type { - ExportResultType, - LocalBackupExportResultType, -} from '../services/backups/types.std.js'; +import type { ExportResultType } from '../services/backups/types.std.js'; import { BackupLevel } from '../services/backups/types.std.js'; const { shuffle } = lodash; @@ -135,11 +132,6 @@ const validateBackupResult: ExportResultType = { }, }; -const exportLocalBackupResult: LocalBackupExportResultType = { - ...validateBackupResult, - snapshotDir: '/home/signaluser/SignalBackups/signal-backup-1745618069169', -}; - const donationAmountsConfig = { cad: { minimum: 4, @@ -468,6 +460,7 @@ export default { isContentProtectionNeeded: true, isMinimizeToAndStartInSystemTraySupported: true, isPlaintextExportEnabled: true, + lastLocalBackup: undefined, lastSyncTime: Date.now(), localeOverride: null, localBackupFolder: undefined, @@ -538,11 +531,6 @@ export default { addCustomColor: action('addCustomColor'), doDeleteAllData: action('doDeleteAllData'), editCustomColor: action('editCustomColor'), - exportLocalBackup: async () => { - return { - result: exportLocalBackupResult, - }; - }, getMessageCountBySchemaVersion: async () => [ { schemaVersion: 10, count: 1024 }, { schemaVersion: 8, count: 256 }, @@ -616,6 +604,7 @@ export default { ), setSettingsLocation: action('setSettingsLocation'), showToast: action('showToast'), + startLocalBackupExport: action('startLocalBackupExport'), startPlaintextExport: action('startPlaintextExport'), validateBackup: async () => { return { @@ -1210,6 +1199,21 @@ LocalBackups.args = { backupFeatureEnabled: true, backupLocalBackupsEnabled: true, backupKeyViewed: true, + lastLocalBackup: { + timestamp: Date.now() - DAY, + backupsFolder: 'backups', + snapshotDir: 'backups/snapshot', + }, + localBackupFolder: '/home/signaluser/Signal Backups/', +}; + +export const LocalBackupsNeverBackedUp = Template.bind({}); +LocalBackupsNeverBackedUp.args = { + settingsLocation: { page: SettingsPage.LocalBackups }, + backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, + backupKeyViewed: true, + lastLocalBackup: undefined, localBackupFolder: '/home/signaluser/Signal Backups/', }; diff --git a/ts/components/Preferences.dom.tsx b/ts/components/Preferences.dom.tsx index e05ca5ee61..e430979ade 100644 --- a/ts/components/Preferences.dom.tsx +++ b/ts/components/Preferences.dom.tsx @@ -98,6 +98,7 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre import type { SmartPreferencesChatFoldersPageProps } from '../state/smart/PreferencesChatFoldersPage.preload.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.preload.js'; +import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js'; const { isNumber, noop, partition } = lodash; @@ -113,6 +114,7 @@ export type PropsDataType = { backupKeyViewed: boolean; backupLocalBackupsEnabled: boolean; backupTier: BackupLevel | null; + lastLocalBackup: LocalBackupExportMetadata | undefined; localBackupFolder: string | undefined; chatFoldersFeatureEnabled: boolean; currentChatFoldersCount: number; @@ -240,7 +242,6 @@ type PropsFunctionType = { addCustomColor: (color: CustomColorType) => unknown; doDeleteAllData: () => unknown; editCustomColor: (colorId: string, color: CustomColorType) => unknown; - exportLocalBackup: () => Promise; getMessageCountBySchemaVersion: () => Promise; getMessageSampleForSchemaVersion: ( version: number @@ -270,6 +271,7 @@ type PropsFunctionType = { ) => unknown; setSettingsLocation: (settingsLocation: SettingsLocation) => unknown; showToast: (toast: AnyToast) => unknown; + startLocalBackupExport: () => void; startPlaintextExport: () => unknown; validateBackup: () => Promise; @@ -405,7 +407,6 @@ export function Preferences({ doDeleteAllData, editCustomColor, emojiSkinToneDefault, - exportLocalBackup, getConversationsWithCustomColor, getMessageCountBySchemaVersion, getMessageSampleForSchemaVersion, @@ -449,6 +450,7 @@ export function Preferences({ isSystemTraySupported, isMinimizeToAndStartInSystemTraySupported, isInternalUser, + lastLocalBackup, lastSyncTime, localBackupFolder, makeSyncRequest, @@ -525,6 +527,7 @@ export function Preferences({ setSettingsLocation, shouldShowUpdateDialog, showToast, + startLocalBackupExport, startPlaintextExport, localeOverride, theme, @@ -2243,6 +2246,7 @@ export function Preferences({ i18n={i18n} isLocalBackupsEnabled={backupLocalBackupsEnabled} isRemoteBackupsEnabled={backupFeatureEnabled} + lastLocalBackup={lastLocalBackup} locale={resolvedLocale} localBackupFolder={localBackupFolder} onBackupKeyViewedChange={onBackupKeyViewedChange} @@ -2253,6 +2257,7 @@ export function Preferences({ refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus} setSettingsLocation={setSettingsLocation} showToast={showToast} + startLocalBackupExport={startLocalBackupExport} /> ); content = ( @@ -2281,7 +2286,6 @@ export function Preferences({ contents={ void; settingsLocation: SettingsLocation; @@ -101,6 +105,7 @@ export function PreferencesBackups({ refreshBackupSubscriptionStatus: () => void; setSettingsLocation: (settingsLocation: SettingsLocation) => void; showToast: ShowToastAction; + startLocalBackupExport: () => void; }): React.JSX.Element | null { const [authError, setAuthError] = useState>(); @@ -156,6 +161,7 @@ export function PreferencesBackups({ accountEntropyPool={accountEntropyPool} backupKeyViewed={backupKeyViewed} i18n={i18n} + lastLocalBackup={lastLocalBackup} localBackupFolder={localBackupFolder} onBackupKeyViewedChange={onBackupKeyViewedChange} settingsLocation={settingsLocation} @@ -163,6 +169,7 @@ export function PreferencesBackups({ promptOSAuth={promptOSAuth} setSettingsLocation={setSettingsLocation} showToast={showToast} + startLocalBackupExport={startLocalBackupExport} /> ); } diff --git a/ts/components/PreferencesInternal.dom.tsx b/ts/components/PreferencesInternal.dom.tsx index 9e22e31847..30f99f4863 100644 --- a/ts/components/PreferencesInternal.dom.tsx +++ b/ts/components/PreferencesInternal.dom.tsx @@ -26,7 +26,6 @@ const log = createLogger('PreferencesInternal'); export function PreferencesInternal({ i18n, - exportLocalBackup: doExportLocalBackup, validateBackup: doValidateBackup, getMessageCountBySchemaVersion, getMessageSampleForSchemaVersion, @@ -40,7 +39,6 @@ export function PreferencesInternal({ setCallQualitySurveyCooldownDisabled, }: { i18n: LocalizerType; - exportLocalBackup: () => Promise; validateBackup: () => Promise; getMessageCountBySchemaVersion: () => Promise; getMessageSampleForSchemaVersion: ( @@ -64,11 +62,6 @@ export function PreferencesInternal({ callQualitySurveyCooldownDisabled: boolean; setCallQualitySurveyCooldownDisabled: (value: boolean) => void; }): React.JSX.Element { - const [isExportPending, setIsExportPending] = useState(false); - const [exportResult, setExportResult] = useState< - BackupValidationResultType | undefined - >(); - const [messageCountBySchemaVersion, setMessageCountBySchemaVersion] = useState(); const [messageSampleForVersions, setMessageSampleForVersions] = useState<{ @@ -151,18 +144,6 @@ export function PreferencesInternal({ [] ); - const exportLocalBackup = useCallback(async () => { - setIsExportPending(true); - setExportResult(undefined); - try { - setExportResult(await doExportLocalBackup()); - } catch (error) { - setExportResult({ error: toLogFormat(error) }); - } finally { - setIsExportPending(false); - } - }, [doExportLocalBackup]); - // Donation receipt states const [isGeneratingReceipt, setIsGeneratingReceipt] = useState(false); @@ -269,40 +250,6 @@ export function PreferencesInternal({ {renderValidationResult(validationResult)} - - -
    - {i18n( - 'icu:Preferences__internal__export-local-backup--description' - )} -
    -
    - - {i18n('icu:Preferences__internal__export-local-backup')} - -
    -
    - - {renderValidationResult(exportResult)} -
    - Fetch data @@ -364,7 +310,6 @@ export function PreferencesInternal({ [schemaVersion]: sampleMessages, }); }} - disabled={isExportPending} > Sample diff --git a/ts/components/PreferencesLocalBackups.dom.tsx b/ts/components/PreferencesLocalBackups.dom.tsx index 0786afc735..f0306c9ff3 100644 --- a/ts/components/PreferencesLocalBackups.dom.tsx +++ b/ts/components/PreferencesLocalBackups.dom.tsx @@ -36,6 +36,8 @@ import type { import { ConfirmationDialog } from './ConfirmationDialog.dom.js'; import { AxoButton } from '../axo/AxoButton.dom.js'; import { SECOND } from '../util/durations/constants.std.js'; +import { formatTimestamp } from '../util/formatTimestamp.dom.js'; +import type { LocalBackupExportMetadata } from '../types/LocalExport.std.js'; const { noop } = lodash; @@ -43,6 +45,7 @@ export function PreferencesLocalBackups({ accountEntropyPool, backupKeyViewed, i18n, + lastLocalBackup, localBackupFolder, onBackupKeyViewedChange, settingsLocation, @@ -50,10 +53,12 @@ export function PreferencesLocalBackups({ promptOSAuth, setSettingsLocation, showToast, + startLocalBackupExport, }: { accountEntropyPool: string | undefined; backupKeyViewed: boolean; i18n: LocalizerType; + lastLocalBackup: LocalBackupExportMetadata | undefined; localBackupFolder: string | undefined; onBackupKeyViewedChange: (keyViewed: boolean) => void; settingsLocation: SettingsLocation; @@ -63,6 +68,7 @@ export function PreferencesLocalBackups({ ) => Promise; setSettingsLocation: (settingsLocation: SettingsLocation) => void; showToast: ShowToastAction; + startLocalBackupExport: () => void; }): React.JSX.Element { const [authError, setAuthError] = React.useState>(); @@ -107,6 +113,13 @@ export function PreferencesLocalBackups({ ); + const lastBackupText = lastLocalBackup + ? formatTimestamp(lastLocalBackup.timestamp, { + dateStyle: 'medium', + timeStyle: 'short', + }) + : i18n('icu:Preferences__local-backups-last-backup-never'); + return ( <>
    @@ -115,6 +128,29 @@ export function PreferencesLocalBackups({
    + +
    + +
    +
    + + {i18n('icu:Preferences__local-backups-backup-now')} + +
    +