From 5b25de10f1505a6d761a6bd467a4b4f814dcc1dc Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Wed, 4 Jun 2025 13:42:00 -0700 Subject: [PATCH] Add settings for local backups --- _locales/en/messages.json | 128 +++++ images/icons/v3/device/device-laptop.svg | 4 + images/icons/v3/folder/folder.svg | 1 + .../v3/signal_backups/signal_backups.svg | 1 + .../BackupMediaDownloadProgress.scss | 4 +- stylesheets/components/Preferences.scss | 491 +++++++++++++++++- ts/components/Preferences.stories.tsx | 46 ++ ts/components/Preferences.tsx | 79 ++- ts/components/PreferencesBackups.tsx | 300 +++++++++-- ts/components/PreferencesLocalBackups.tsx | 400 ++++++++++++++ ts/components/ToastManager.stories.tsx | 2 + ts/components/ToastManager.tsx | 8 + ts/services/backups/index.ts | 12 + ts/state/smart/Preferences.tsx | 22 +- ts/types/PreferencesBackupPage.ts | 25 + ts/types/Storage.d.ts | 3 + ts/types/Toast.tsx | 2 + ts/util/isLocalBackupsEnabled.ts | 19 + ts/util/lint/exceptions.json | 8 + 19 files changed, 1485 insertions(+), 70 deletions(-) create mode 100644 images/icons/v3/device/device-laptop.svg create mode 100644 images/icons/v3/folder/folder.svg create mode 100644 images/icons/v3/signal_backups/signal_backups.svg create mode 100644 ts/components/PreferencesLocalBackups.tsx create mode 100644 ts/types/PreferencesBackupPage.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c779a1ccda..b7ce211adf 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6660,6 +6660,18 @@ "messageformat": "Internal", "description": "Button to switch the settings view to control internal configuration" }, + "icu:Preferences__button--manage": { + "messageformat": "Manage", + "description": "Button in the settings view to manage details of a feature or setting." + }, + "icu:Preferences__button--choose-folder": { + "messageformat": "Choose folder", + "description": "Button in the settings view to choose a folder using the computer's file browser." + }, + "icu:Preferences__button--set-up": { + "messageformat": "Set up", + "description": "Button in the settings view to initially setup a feature or setting." + }, "icu:Preferences__internal__local-backups": { "messageformat": "Local backups", "description": "Text header for internal local backup tools" @@ -6720,6 +6732,18 @@ "messageformat": "Blocked", "description": "Label for blocked contacts setting" }, + "icu:Preferences--signal-backups": { + "messageformat": "Signal Backups", + "description": "Feature name for message backups using the Signal service." + }, + "icu:Preferences--signal-backups-off-description": { + "messageformat": "Automatic backups with Signal’s secure, end-to-end encrypted storage service. Get started on your phone. Learn more.", + "description": "Description for message backups using the Signal service." + }, + "icu:Preferences--backup-section-description": { + "messageformat": "Back up your message history so you never lose data when you get a new phone or reinstall Signal.", + "description": "Section description in the main backup settings section which summarizes the message backup feature." + }, "icu:Preferences--backup-details__header": { "messageformat": "Backup details", "description": "Section title for info on your current backup (created time & size)" @@ -6776,6 +6800,110 @@ "messageformat": "Backup size", "description": "Label for the size (e.g. 1.4 GB) of this user's backup" }, + "icu:Preferences__local-backups": { + "messageformat": "Desktop backups", + "description": "Section title and button text for the on-device backups feature (also known as local backups)." + }, + "icu:Preferences__local-backups-section__description": { + "messageformat": "Backups are encrypted with a key and stored on your computer.", + "description": "Description in the settings view for on-device backups." + }, + "icu:Preferences--local-backups-off-description": { + "messageformat": "Create an end-to-end encrypted backup that you can restore on your phone. ", + "description": "Description for on-device local message backups when the feature is off." + }, + "icu:Preferences--local-backups-restore-info": { + "messageformat": "To restore a backup, install a new copy of Signal. Open the app and tap Restore backup, then locate the backup folder. Learn more.", + "description": "Description for how to restore on-device local message backups." + }, + "icu:Preferences--local-backups-setup-folder": { + "messageformat": "Choose a folder", + "description": "Title for picking a folder for on-device local message backups when setting it up for the first time." + }, + "icu:Preferences--local-backups-setup-folder-description": { + "messageformat": "Choose a folder in your computer’s storage where your end-to-end encrypted backup will be stored.", + "description": "Description for picking a folder for on-device local message backups when setting it up for the first time." + }, + "icu:Preferences--local-backups-setup-next": { + "messageformat": "Next", + "description": "Button to continue with setup of local on-device backups, shown while reviewing your backup key for the first time." + }, + "icu:Preferences--local-backups-view-backup-key-done": { + "messageformat": "Done", + "description": "Button to dismiss the backup key viewer, shown when reviewing your backup key for local on-device backups." + }, + "icu:Preferences--local-backups-backup-key-text-box": { + "messageformat": "Backup key text box", + "description": "ARIA label for the text box used to view or confirm the backup key for local message backups." + }, + "icu:Preferences--local-backups-enter-backup-key": { + "messageformat": "Enter key", + "description": "Placeholder text for the text box used to confirm the backup key for local message backups." + }, + "icu:Preferences--local-backups-record-backup-key": { + "messageformat": "Record your backup key", + "description": "Title for viewing the backup key for local message backups." + }, + "icu:Preferences--local-backups-record-backup-key-description": { + "messageformat": "This key is required to recover your account and data. Store this key somewhere safe. If you lose it, you won’t be able to recover your account. Learn more.", + "description": "Description for viewing the backup key for local message backups." + }, + "icu:Preferences--local-backups-confirm-backup-key": { + "messageformat": "Confirm your backup key", + "description": "Title for confirming the backup key for local message backups by re-entering it into a text box." + }, + "icu:Preferences--local-backups-confirm-backup-key-description": { + "messageformat": "Enter the backup key that you just recorded", + "description": "Description for confirming the backup key for local message backups by re-entering it into a text box." + }, + "icu:Preferences__local-backups-confirm-key-modal-title": { + "messageformat": "Keep your key safe", + "description": "Title of modal shown after you confirmed the backup key for local on-device backups in settings" + }, + "icu:Preferences__local-backups-confirm-key-modal-body": { + "messageformat": "Signal will not be able to help you restore your backup if you lose your key. Store it somewhere safe and secure, and do not share it with others.", + "description": "Body text of modal shown after you confirmed the backup key for local on-device backups in settings" + }, + "icu:Preferences__local-backups-confirm-key-modal-continue": { + "messageformat": "Continue", + "description": "Text of the primary button on modal shown after you confirmed the backup key for local on-device backups in settings" + }, + "icu:Preferences--local-backups-see-backup-key-again": { + "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-folder": { + "messageformat": "Backup folder", + "description": "Label for current folder in which local on-device backups are stored." + }, + "icu:Preferences__local-backups-folder__change": { + "messageformat": "Change", + "description": "Button to change the folder in which local on-device backups are stored." + }, + "icu:Preferences__local-backups-copy-key": { + "messageformat": "Copy to clipboard", + "description": "Button label for copying the backup key to clipboard in the settings for local on-device backups" + }, + "icu:Preferences__local-backups-copied-key": { + "messageformat": "Backup key copied", + "description": "Toast message after you copied the backup key to clipboard from settings for local on-device backups" + }, + "icu:Preferences__view-key": { + "messageformat": "View key", + "description": "Button to view the backup key which is used to restore a message history backup" + }, + "icu:Preferences__backup-key": { + "messageformat": "Backup key", + "description": "Name for the key code used to restore a message history backup" + }, + "icu:Preferences__backup-key-description": { + "messageformat": "Your backup key is a 64-digit code used to restore your backup", + "description": "Description for the key code used to restore a message history backup" + }, + "icu:Preferences__backup-other-ways": { + "messageformat": "Other ways to back up", + "description": "Heading on the backups settings view for alternative backup methods such as on-device backups." + }, "icu:Preferences--blocked-count": { "messageformat": "{num, plural, one {# contact} other {# contacts}}", "description": "Number of contacts blocked plural" diff --git a/images/icons/v3/device/device-laptop.svg b/images/icons/v3/device/device-laptop.svg new file mode 100644 index 0000000000..307280075a --- /dev/null +++ b/images/icons/v3/device/device-laptop.svg @@ -0,0 +1,4 @@ + + + + diff --git a/images/icons/v3/folder/folder.svg b/images/icons/v3/folder/folder.svg new file mode 100644 index 0000000000..0c3db77081 --- /dev/null +++ b/images/icons/v3/folder/folder.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/signal_backups/signal_backups.svg b/images/icons/v3/signal_backups/signal_backups.svg new file mode 100644 index 0000000000..f0e6127272 --- /dev/null +++ b/images/icons/v3/signal_backups/signal_backups.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/components/BackupMediaDownloadProgress.scss b/stylesheets/components/BackupMediaDownloadProgress.scss index c9eb755c05..a1a9af7a4c 100644 --- a/stylesheets/components/BackupMediaDownloadProgress.scss +++ b/stylesheets/components/BackupMediaDownloadProgress.scss @@ -46,13 +46,13 @@ height: 24px; @include mixins.light-theme { @include mixins.color-svg( - '../images/icons/v3/backup/backup-bold.svg', + '../images/icons/v3/signal_backups.svg', variables.$color-ultramarine ); } @include mixins.dark-theme { @include mixins.color-svg( - '../images/icons/v3/backup/backup-bold.svg', + '../images/icons/v3/signal_backups.svg', variables.$color-ultramarine-light ); } diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index ed2f7396c3..50569d83a9 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -263,7 +263,9 @@ $secondary-text-color: light-dark( } &--backups { - @include preferences-icon('../images/icons/v3/backup/backup-bold.svg'); + @include preferences-icon( + '../images/icons/v3/signal_backups/signal_backups.svg' + ); } &--internal { @@ -287,10 +289,12 @@ $secondary-text-color: light-dark( } &__settings-pane { - padding-top: 24px; + display: flex; + flex-grow: 1; + flex-direction: column; height: 100%; width: 100%; - flex-grow: 1; + padding-top: 8px; max-width: 750px; &::-webkit-scrollbar-corner { @@ -298,6 +302,10 @@ $secondary-text-color: light-dark( } } + &__settings-pane-content--with-footer { + height: 100%; + } + &__settings-pane-spacer { flex-grow: 1; min-width: 0; @@ -314,14 +322,6 @@ $secondary-text-color: light-dark( flex-shrink: 0; position: relative; - border-bottom: 1px solid variables.$color-gray-15; - @include mixins.light-theme { - border-color: variables.$color-gray-15; - } - @include mixins.dark-theme { - border-color: variables.$color-gray-65; - } - &--backups { border: none; margin-bottom: 16px; @@ -483,6 +483,11 @@ $secondary-text-color: light-dark( variables.$color-gray-02 ); } + + // Keep the title centered when a back icon is on the left + & + .Preferences__title--header { + margin-inline-end: 32px; + } } &__stories-off { @@ -690,6 +695,20 @@ $secondary-text-color: light-dark( } } +.Preferences--BackupsRow { + padding-block: 8px; + margin-block-start: 8px; + + &:not(:last-child) { + padding-block-end: 24px; + } +} + +.Preferences--BackupsRow .Preferences__control { + padding-block: 10px; + align-items: initial; +} + .Preferences--backup-details { margin-block-start: 30px; @@ -716,6 +735,456 @@ $secondary-text-color: light-dark( } } +.Preferences__BackupsIcon { + @include mixins.light-theme { + @include mixins.color-svg( + '../images/icons/v3/signal_backups/signal_backups.svg', + variables.$color-gray-75 + ); + } + @include mixins.dark-theme { + @include mixins.color-svg( + '../images/icons/v3/signal_backups/signal_backups.svg', + variables.$color-gray-15 + ); + } +} + +.Preferences__LocalBackupsIcon { + @include mixins.light-theme { + @include mixins.color-svg( + '../images/icons/v3/device/device-laptop.svg', + variables.$color-gray-75 + ); + } + @include mixins.dark-theme { + @include mixins.color-svg( + '../images/icons/v3/device/device-laptop.svg', + variables.$color-gray-15 + ); + } +} + +.Preferences--LocalBackupsSetupScreen { + display: flex; + flex-direction: column; + text-align: center; +} + +.Preferences--LocalBackupsSetupScreenHeader { + @include mixins.font-title-2; + margin-block: 8px; +} + +.Preferences--LocalBackupsSetupScreenPane { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.Preferences--LocalBackupsSetupScreenPane-top { + min-height: 154px; +} + +.Preferences--LocalBackupsSetupScreenPaneContent { + display: block; + width: 100%; +} + +.Preferences--LocalBackupsSetupScreenCopyButton { + @include mixins.font-body-small; + padding-inline: 15px 21px; + font-weight: 500; + vertical-align: text-top; + + &::before { + content: ''; + display: inline-block; + height: 16px; + width: 16px; + margin-inline-end: 6px; + @include mixins.color-svg( + '../images/icons/v3/copy/copy-compact.svg', + variables.$color-black + ); + } +} + +.Preferences--LocalBackupsSetupScreenPane-footer { + flex-direction: row; + flex-grow: 0; + flex-shrink: 1; + padding-block: 24px; +} + +.Preferences--LocalBackupsSetupScreenFooterSection { + display: flex; + flex-grow: 1; +} + +.Preferences--LocalBackupsSetupScreenFooterSection-right { + justify-content: right; +} + +.Preferences--LocalBackupsSetupScreenFooterSeeKeyButton { + @include mixins.font-body-1-bold; + padding-block: 0; + padding-inline: 0; + background: none; + border: none; + outline: none; + color: variables.$color-ultramarine; + + @include mixins.keyboard-mode { + &:focus { + outline: 2px solid variables.$color-ultramarine; + } + } +} + +.Preferences--LocalBackupsSetupScreenFooterButton { + padding-inline: 34px; +} + +.Preferences--LocalBackupsSetupScreenBody { + @include mixins.font-body-1; + margin-block: 8px; + color: $secondary-text-color; +} + +.Preferences--LocalBackupsSetupScreenBody--folder { + margin-block-end: 57px; +} + +.Preferences--LocalBackupsBackupKey { + width: 274px; + height: 201px; + padding-block: 28px; + padding-inline: 36px; + margin-block: 28px 20px; + background: variables.$color-gray-02; + border-radius: 12px; + border-width: 0; + outline: none; + color: variables.$color-gray-90; + font-family: variables.$monospace; + font-size: 16px; + font-weight: 400; + line-height: 36.128px; + letter-spacing: 0.624px; + overflow: hidden; + resize: none; + word-break: break-all; + text-transform: uppercase; + + &::placeholder { + color: variables.$color-gray-45; + text-transform: none; + } +} + +.Preferences--LocalBackupsSetupIcon { + display: inline-flex; + width: 64px; + height: 64px; + border-radius: 64px; + background: variables.$color-ultramarine-pale; + align-items: center; + justify-content: center; + + &::before { + height: 38px; + width: 38px; + content: ''; + } +} + +.Preferences--LocalBackupsSetupIcon-folder { + margin-block-start: 60px; + margin-block-end: 12px; + + &::before { + @include mixins.color-svg( + '../images/icons/v3/folder/folder.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsSetupIcon-key { + &::before { + @include mixins.color-svg( + '../images/icons/v3/key/key.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsSetupIcon-lock { + &::before { + @include mixins.color-svg( + '../images/icons/v3/lock/lock.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsConfirmKeyModal { + padding-block: 36px 20px; + padding-inline: 32px; + text-align: center; +} + +.Preferences--LocalBackupsConfirmKeyModal__body { + padding: 0; +} + +.Preferences--LocalBackupsConfirmKeyModalTitle { + @include mixins.font-title-medium; + margin-block: 12px; +} + +.Preferences--LocalBackupsConfirmKeyModalBody { + @include mixins.font-body-1; + margin-block: 8px 32px; + color: $secondary-text-color; +} + +.Preferences--LocalBackupsConfirmKeyModalButton { + padding-inline: 32px; +} + +.Preferences--LocalBackupsConfirmKeyModal .module-Modal__button-footer { + justify-content: center; +} + +.Preferences__BackupsIcon { + @include mixins.light-theme { + @include mixins.color-svg( + '../images/icons/v3/signal_backups/signal_backups.svg', + variables.$color-gray-75 + ); + } + @include mixins.dark-theme { + @include mixins.color-svg( + '../images/icons/v3/signal_backups/signal_backups.svg', + variables.$color-gray-15 + ); + } +} + +.Preferences__LocalBackupsIcon { + @include mixins.light-theme { + @include mixins.color-svg( + '../images/icons/v3/device/device-laptop.svg', + variables.$color-gray-75 + ); + } + @include mixins.dark-theme { + @include mixins.color-svg( + '../images/icons/v3/device/device-laptop.svg', + variables.$color-gray-15 + ); + } +} + +.Preferences--LocalBackupsSetupScreen { + display: flex; + flex-direction: column; + text-align: center; +} + +.Preferences--LocalBackupsSetupScreenHeader { + @include mixins.font-title-2; + margin-block: 8px; +} + +.Preferences--LocalBackupsSetupScreenPane { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.Preferences--LocalBackupsSetupScreenPane-top { + flex-grow: 0; + min-height: 154px; +} + +.Preferences--LocalBackupsSetupScreenPaneContent { + display: block; + width: 100%; +} + +.Preferences--LocalBackupsSetupScreenCopyButton { + @include mixins.font-body-small; + padding-inline: 15px 21px; + font-weight: 500; + vertical-align: text-top; + + &::before { + content: ''; + display: inline-block; + height: 16px; + width: 16px; + margin-inline-end: 6px; + @include mixins.color-svg( + '../images/icons/v3/copy/copy-compact.svg', + variables.$color-black + ); + } +} + +.Preferences--LocalBackupsSetupScreenPane-footer { + flex-direction: row; + flex-grow: 0; + flex-shrink: 1; +} + +.Preferences--LocalBackupsSetupScreenFooterSection { + display: flex; + flex-grow: 1; +} + +.Preferences--LocalBackupsSetupScreenFooterSection-right { + justify-content: right; +} + +.Preferences--LocalBackupsSetupScreenFooterSeeKeyButton { + @include mixins.font-body-1-bold; + padding-block: 0; + padding-inline: 0; + background: none; + border: none; + outline: none; + color: variables.$color-ultramarine; + + @include mixins.keyboard-mode { + &:focus { + outline: 2px solid variables.$color-ultramarine; + } + } +} + +.Preferences--LocalBackupsSetupScreenFooterButton { + padding-inline: 34px; +} + +.Preferences--LocalBackupsSetupScreenBody { + @include mixins.font-body-1; + margin-block: 8px; + color: $secondary-text-color; +} + +.Preferences--LocalBackupsSetupScreenBody a { + text-decoration: none; +} + +.Preferences--LocalBackupsSetupScreenBody--folder { + margin-block-end: 57px; +} + +.Preferences--LocalBackupsBackupKey { + width: 274px; + height: 201px; + padding-block: 28px; + padding-inline: 36px; + margin-block: 28px 20px; + background: variables.$color-gray-02; + border-radius: 12px; + border-width: 0; + outline: none; + color: variables.$color-gray-90; + font-family: variables.$monospace; + font-size: 16px; + font-weight: 400; + line-height: 36.128px; + letter-spacing: 0.624px; + overflow: hidden; + resize: none; + word-break: break-all; + text-transform: uppercase; + + &::placeholder { + color: variables.$color-gray-45; + text-transform: none; + } +} + +.Preferences--LocalBackupsSetupIcon { + display: inline-flex; + width: 64px; + height: 64px; + border-radius: 64px; + background: variables.$color-ultramarine-pale; + align-items: center; + justify-content: center; + + &::before { + height: 38px; + width: 38px; + content: ''; + } +} + +.Preferences--LocalBackupsSetupIcon-folder { + margin-block-start: 60px; + margin-block-end: 12px; + + &::before { + @include mixins.color-svg( + '../images/icons/v3/folder/folder.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsSetupIcon-key { + &::before { + @include mixins.color-svg( + '../images/icons/v3/key/key.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsSetupIcon-lock { + &::before { + @include mixins.color-svg( + '../images/icons/v3/lock/lock.svg', + variables.$color-ultramarine-logo + ); + } +} + +.Preferences--LocalBackupsConfirmKeyModal { + padding-block: 36px 20px; + padding-inline: 32px; + text-align: center; +} + +.Preferences--LocalBackupsConfirmKeyModal__body { + padding: 0; +} + +.Preferences--LocalBackupsConfirmKeyModalTitle { + @include mixins.font-title-medium; + margin-block: 12px; +} + +.Preferences--LocalBackupsConfirmKeyModalBody { + @include mixins.font-body-1; + margin-block: 8px 32px; + color: $secondary-text-color; +} + +.Preferences--LocalBackupsConfirmKeyModalButton { + padding-inline: 32px; +} + +.Preferences--LocalBackupsConfirmKeyModal .module-Modal__button-footer { + justify-content: center; +} + .Preferences--internal--result { padding-inline: 48px 24px; max-width: 100%; diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 70879670b2..df386ae3c6 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -158,6 +158,8 @@ export default { args: { i18n, + accountEntropyPool: + 'uy38jh2778hjjhj8lk19ga61s672jsj089r023s6a57809bap92j2yh5t326vv7t', autoDownloadAttachment: { photos: true, videos: false, @@ -186,6 +188,8 @@ export default { availableMicrophones, availableSpeakers, backupFeatureEnabled: false, + backupKeyViewed: false, + backupLocalBackupsEnabled: false, badge: undefined, blockedCount: 0, customColors: {}, @@ -233,6 +237,7 @@ export default { isUpdateDownloaded: false, lastSyncTime: Date.now(), localeOverride: null, + localBackupFolder: undefined, me, navTabsCollapsed: false, notificationContent: 'name', @@ -283,6 +288,7 @@ export default { onAutoDownloadAttachmentChange: action('onAutoDownloadAttachmentChange'), onAutoDownloadUpdateChange: action('onAutoDownloadUpdateChange'), onAutoLaunchChange: action('onAutoLaunchChange'), + onBackupKeyViewedChange: action('onBackupKeyViewedChange'), onCallNotificationsChange: action('onCallNotificationsChange'), onCallRingtoneNotificationChange: action( 'onCallRingtoneNotificationChange' @@ -321,6 +327,8 @@ export default { onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'), onWhoCanFindMeChange: action('onWhoCanFindMeChange'), onZoomFactorChange: action('onZoomFactorChange'), + pickLocalBackupFolder: () => + Promise.resolve('/home/signaluser/Signal Backups/'), refreshCloudBackupStatus: action('refreshCloudBackupStatus'), refreshBackupSubscriptionStatus: action('refreshBackupSubscriptionStatus'), removeCustomColor: action('removeCustomColor'), @@ -421,6 +429,7 @@ export const BackupsPaidActive = Template.bind({}); BackupsPaidActive.args = { page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, cloudBackupStatus: { mediaSize: 539_249_410_039, protoSize: 100_000_000, @@ -440,6 +449,7 @@ export const BackupsPaidCancelled = Template.bind({}); BackupsPaidCancelled.args = { page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, cloudBackupStatus: { mediaSize: 539_249_410_039, protoSize: 100_000_000, @@ -459,6 +469,7 @@ export const BackupsFree = Template.bind({}); BackupsFree.args = { page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, backupSubscriptionStatus: { status: 'free', mediaIncludedInBackupDurationDays: 30, @@ -467,13 +478,23 @@ BackupsFree.args = { export const BackupsOff = Template.bind({}); BackupsOff.args = { + page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, +}; + +export const BackupsLocalBackups = Template.bind({}); +BackupsLocalBackups.args = { + page: Page.Backups, + backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, }; export const BackupsSubscriptionNotFound = Template.bind({}); BackupsSubscriptionNotFound.args = { page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, backupSubscriptionStatus: { status: 'not-found', }, @@ -488,11 +509,36 @@ export const BackupsSubscriptionExpired = Template.bind({}); BackupsSubscriptionExpired.args = { page: Page.Backups, backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, backupSubscriptionStatus: { status: 'expired', }, }; +export const LocalBackups = Template.bind({}); +LocalBackups.args = { + page: Page.LocalBackups, + backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, + backupKeyViewed: true, + localBackupFolder: '/home/signaluser/Signal Backups/', +}; + +export const LocalBackupsSetupChooseFolder = Template.bind({}); +LocalBackupsSetupChooseFolder.args = { + page: Page.LocalBackupsSetupFolder, + backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, +}; + +export const LocalBackupsSetupViewBackupKey = Template.bind({}); +LocalBackupsSetupViewBackupKey.args = { + page: Page.LocalBackupsSetupKey, + backupFeatureEnabled: true, + backupLocalBackupsEnabled: true, + localBackupFolder: '/home/signaluser/Signal Backups/', +}; + export const UpdateAvailable = Template.bind({}); UpdateAvailable.args = { hasPendingUpdate: true, diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index afad6e8f48..acf586339e 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -82,14 +82,20 @@ import type { UnreadStats } from '../util/countUnreadStats'; import type { BadgeType } from '../badges/types'; import type { MessageCountBySchemaVersionType } from '../sql/Interface'; import type { MessageAttributesType } from '../model-types'; +import { isBackupPage } from '../types/PreferencesBackupPage'; +import type { PreferencesBackupPage } from '../types/PreferencesBackupPage'; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; export type PropsDataType = { // Settings + accountEntropyPool: string | undefined; autoDownloadAttachment: AutoDownloadAttachmentType; backupFeatureEnabled: boolean; + backupKeyViewed: boolean; + backupLocalBackupsEnabled: boolean; + localBackupFolder: string | undefined; blockedCount: number; cloudBackupStatus?: BackupStatusType; backupSubscriptionStatus?: BackupsSubscriptionType; @@ -195,6 +201,7 @@ type PropsFunctionType = { getConversationsWithCustomColor: (colorId: string) => Array; makeSyncRequest: () => unknown; onStartUpdate: () => unknown; + pickLocalBackupFolder: () => Promise; refreshCloudBackupStatus: () => void; refreshBackupSubscriptionStatus: () => void; removeCustomColor: (colorId: string) => unknown; @@ -221,6 +228,7 @@ type PropsFunctionType = { ) => unknown; onAutoDownloadUpdateChange: CheckboxChangeHandlerType; onAutoLaunchChange: CheckboxChangeHandlerType; + onBackupKeyViewedChange: (keyViewed: boolean) => void; onCallNotificationsChange: CheckboxChangeHandlerType; onCallRingtoneNotificationChange: CheckboxChangeHandlerType; onContentProtectionChange: CheckboxChangeHandlerType; @@ -277,6 +285,11 @@ export enum Page { // Sub pages ChatColor = 'ChatColor', PNP = 'PNP', + BackupsDetails = 'BackupsDetails', + LocalBackups = 'LocalBackups', + LocalBackupsSetupFolder = 'LocalBackupsSetupFolder', + LocalBackupsSetupKey = 'LocalBackupsSetupKey', + LocalBackupsKeyReference = 'LocalBackupsKeyReference', } enum LanguageDialog { @@ -308,6 +321,7 @@ const DEFAULT_ZOOM_FACTORS = [ ]; export function Preferences({ + accountEntropyPool, addCustomColor, autoDownloadAttachment, availableCameras, @@ -315,7 +329,9 @@ export function Preferences({ availableMicrophones, availableSpeakers, backupFeatureEnabled, + backupKeyViewed, backupSubscriptionStatus, + backupLocalBackupsEnabled, badge, blockedCount, cloudBackupStatus, @@ -369,6 +385,7 @@ export function Preferences({ isInternalUser, isUpdateDownloaded, lastSyncTime, + localBackupFolder, makeSyncRequest, me, navTabsCollapsed, @@ -378,6 +395,7 @@ export function Preferences({ onAutoDownloadAttachmentChange, onAutoDownloadUpdateChange, onAutoLaunchChange, + onBackupKeyViewedChange, onCallNotificationsChange, onCallRingtoneNotificationChange, onContentProtectionChange, @@ -412,6 +430,7 @@ export function Preferences({ otherTabsUnreadStats, page, phoneNumber = '', + pickLocalBackupFolder, preferredSystemLocales, preferredWidthFromStorage, refreshCloudBackupStatus, @@ -469,7 +488,8 @@ export function Preferences({ setSelectedLanguageLocale(localeOverride); } const shouldShowBackupsPage = - backupFeatureEnabled && backupSubscriptionStatus != null; + (backupFeatureEnabled && backupSubscriptionStatus != null) || + backupLocalBackupsEnabled; if (page === Page.Backups && !shouldShowBackupsPage) { setPage(Page.General); @@ -1881,19 +1901,54 @@ export function Preferences({ title={i18n('icu:Preferences__pnp--page-title')} /> ); - } else if (page === Page.Backups) { + } else if (isBackupPage(page)) { + let pageTitle: string | undefined; + if (page === Page.Backups || page === Page.BackupsDetails) { + pageTitle = i18n('icu:Preferences__button--backups'); + } else if (page === Page.LocalBackups) { + pageTitle = i18n('icu:Preferences__local-backups'); + } + // Local backups setup page titles intentionally left blank + + let backPage: PreferencesBackupPage | undefined; + if (page === Page.LocalBackupsKeyReference) { + backPage = Page.LocalBackups; + } else if (page !== Page.Backups) { + backPage = Page.Backups; + } + let backButton: JSX.Element | undefined; + if (backPage) { + backButton = ( + + } + /> + + ) : ( + + + {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')} + +
+ {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-title')} + +
+ {i18n('icu:Preferences__local-backups-confirm-key-modal-body')} +
+ + )} +
+
+
+ + {title} + +
+ {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 ( +