Add settings for local backups

This commit is contained in:
ayumi-signal 2025-06-04 13:42:00 -07:00 committed by GitHub
parent c36b8d3b6b
commit 5b25de10f1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1485 additions and 70 deletions

View file

@ -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 Signals secure, end-to-end encrypted storage service. Get started on your phone. <learnMoreLink>Learn more.</learnMoreLink>",
"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. <learnMoreLink>Learn more.</learnMoreLink>",
"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 computers 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 wont be able to recover your account. <learnMoreLink>Learn more.</learnMoreLink>",
"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"

View file

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M12.5 7.875C12.9832 7.875 13.375 7.48325 13.375 7C13.375 6.51675 12.9832 6.125 12.5 6.125H11.5C11.0168 6.125 10.625 6.51675 10.625 7C10.625 7.48325 11.0168 7.875 11.5 7.875H12.5Z" fill="black" style="fill:black;fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M6.96412 3.625H17.0359C17.7054 3.62499 18.2605 3.62498 18.7132 3.66197C19.1842 3.70045 19.6209 3.78329 20.0322 3.99285C20.6673 4.31643 21.1836 4.83274 21.5071 5.46778C21.7167 5.87908 21.7996 6.31576 21.838 6.78675C21.875 7.23946 21.875 7.79456 21.875 8.4641V17.25H22.5C23.1904 17.25 23.75 17.8096 23.75 18.5C23.75 19.1904 23.1904 19.75 22.5 19.75H1.5C0.809644 19.75 0.25 19.1904 0.25 18.5C0.25 17.8096 0.809644 17.25 1.5 17.25H2.125L2.125 8.46412C2.12499 7.79457 2.12498 7.23947 2.16197 6.78675C2.20045 6.31576 2.28329 5.87908 2.49285 5.46778C2.81643 4.83274 3.33274 4.31643 3.96778 3.99285C4.37908 3.78329 4.81576 3.70045 5.28675 3.66197C5.73947 3.62498 6.29457 3.62499 6.96412 3.625ZM20.125 8.5V17.25H3.875V8.5C3.875 7.7855 3.87568 7.30224 3.90615 6.92926C3.93579 6.56649 3.9892 6.38575 4.05211 6.26227C4.20791 5.9565 4.4565 5.70791 4.76227 5.55212C4.88575 5.4892 5.06649 5.43579 5.42926 5.40616C5.80224 5.37568 6.2855 5.375 7 5.375H17C17.7145 5.375 18.1978 5.37568 18.5707 5.40616C18.9335 5.43579 19.1143 5.4892 19.2377 5.55212C19.5435 5.70791 19.7921 5.9565 19.9479 6.26227C20.0108 6.38575 20.0642 6.56649 20.0938 6.92926C20.1243 7.30224 20.125 7.7855 20.125 8.5Z" fill="black" style="fill:black;fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.354 5v7.697c0 .674 0 1.224.037 1.671.037.462.118.877.315 1.265a3.23 3.23 0 0 0 1.411 1.41c.388.198.803.279 1.265.316.447.037.997.037 1.67.037h7.895c.674 0 1.224 0 1.671-.037.462-.037.877-.118 1.265-.315a3.23 3.23 0 0 0 1.41-1.411c.198-.388.279-.803.316-1.265.037-.447.037-.997.037-1.67V8.135c0-.674 0-1.224-.037-1.67-.037-.463-.118-.878-.315-1.265a3.23 3.23 0 0 0-1.411-1.412c-.388-.197-.803-.277-1.265-.315-.447-.037-.997-.037-1.67-.037h-4.79c-.345 0-.676-.13-.927-.366L8.1 2.948a2.813 2.813 0 0 0-1.924-.76h-2.01A2.813 2.813 0 0 0 1.355 5Zm15.802 4.5c.03.381.032.871.032 1.583v1.584c0 .712-.001 1.202-.032 1.582-.03.372-.086.574-.162.722a1.77 1.77 0 0 1-.773.774c-.148.075-.35.13-.722.16-.38.032-.87.033-1.582.033H6.083c-.712 0-1.202-.001-1.582-.032-.372-.03-.574-.086-.722-.162a1.771 1.771 0 0 1-.774-.773c-.075-.148-.13-.35-.16-.722-.032-.38-.033-.87-.033-1.582v-1.584c0-.712.001-1.202.032-1.582.03-.372.086-.574.161-.722.17-.333.441-.604.774-.773.148-.076.35-.131.722-.162.38-.03.87-.032 1.582-.032h7.834c.712 0 1.202.001 1.582.032.372.03.574.086.722.162.333.17.604.44.773.773.076.148.131.35.162.722ZM2.812 5c0-.748.607-1.354 1.355-1.354h2.01c.343 0 .674.13.925.366l.132.123c.52.489 1.209.76 1.923.76h4.76c.712 0 1.202.001 1.582.032.372.03.574.087.722.162.333.17.604.44.773.774.05.096.097.214.121.389.041.296.108.409.477.77l-.091.088a2.97 2.97 0 0 0-.618-.404c-.388-.197-.803-.278-1.265-.315-.447-.037-.997-.037-1.67-.037H6.052c-.674 0-1.224 0-1.671.037-.462.037-.877.118-1.265.315a3.229 3.229 0 0 0-.665.452l-.068-.062c.353-.357.428-.507.428-.815V5Z" fill="#000"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -0,0 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#signal_backups__a)" fill="#000"><path d="M5.526 3.85A7.567 7.567 0 0 1 10 2.396c1.673 0 3.218.54 4.474 1.454a.73.73 0 1 0 .859-1.178A9.025 9.025 0 0 0 10 .937a9.025 9.025 0 0 0-5.333 1.735.73.73 0 1 0 .86 1.178ZM3.85 5.527a.73.73 0 0 0-1.178-.86A9.025 9.025 0 0 0 .937 10a9.03 9.03 0 0 0 2.24 5.965l-.255.153a.625.625 0 0 0 .166 1.141l2.343.6c.46.117.879-.3.76-.76l-.599-2.344a.625.625 0 0 0-1.141-.166l-.22.366A7.572 7.572 0 0 1 2.396 10 7.57 7.57 0 0 1 3.85 5.527Zm13.478-.859a.73.73 0 1 0-1.178.859A7.567 7.567 0 0 1 17.604 10a7.57 7.57 0 0 1-1.454 4.473.73.73 0 0 0 1.178.86A9.025 9.025 0 0 0 19.063 10a9.025 9.025 0 0 0-1.735-5.332Zm-1.995 12.66a.73.73 0 1 0-.86-1.178 7.584 7.584 0 0 1-1.853.99.73.73 0 0 0 .503 1.37 9.044 9.044 0 0 0 2.21-1.182Zm-7.113.067a.73.73 0 1 0-.34 1.418 9.08 9.08 0 0 0 2.472.243.73.73 0 0 0-.056-1.457 7.647 7.647 0 0 1-2.076-.204ZM9.583 4.167c-.34 0-.62.27-.631.61l-.2 6.008a.833.833 0 0 0 .897.879l4.328-.197a.635.635 0 0 0 0-1.268l-3.587-.163-.175-5.258a.632.632 0 0 0-.632-.611Z"/></g><defs><clipPath id="signal_backups__a"><path fill="#fff" d="M0 0h20v20H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View file

@ -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
);
}

View file

@ -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%;

View file

@ -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,

View file

@ -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<T = string | number> = (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<ConversationType>;
makeSyncRequest: () => unknown;
onStartUpdate: () => unknown;
pickLocalBackupFolder: () => Promise<string | undefined>;
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 = (
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => setPage(backPage)}
type="button"
/>
);
}
const pageContents = (
<PreferencesBackups
accountEntropyPool={accountEntropyPool}
backupKeyViewed={backupKeyViewed}
backupSubscriptionStatus={backupSubscriptionStatus}
cloudBackupStatus={cloudBackupStatus}
i18n={i18n}
locale={resolvedLocale}
localBackupFolder={localBackupFolder}
onBackupKeyViewedChange={onBackupKeyViewedChange}
pickLocalBackupFolder={pickLocalBackupFolder}
page={page}
setPage={setPage}
showToast={showToast}
/>
);
content = (
<PreferencesContent
contents={
<PreferencesBackups
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={resolvedLocale}
/>
}
backButton={backButton}
contents={pageContents}
contentsRef={settingsPaneRef}
title={i18n('icu:Preferences__button--backups')}
title={pageTitle}
/>
);
} else if (page === Page.Internal) {
@ -2068,7 +2123,7 @@ export function Preferences({
className={classNames({
Preferences__button: true,
'Preferences__button--backups': true,
'Preferences__button--selected': page === Page.Backups,
'Preferences__button--selected': isBackupPage(page),
})}
onClick={() => setPage(Page.Backups)}
>

View file

@ -8,71 +8,174 @@ import type {
} from '../types/backups';
import type { LocalizerType } from '../types/I18N';
import { formatTimestamp } from '../util/formatTimestamp';
import { formatFileSize } from '../util/formatFileSize';
import { SettingsRow } from './PreferencesUtil';
import { SettingsControl as Control, SettingsRow } from './PreferencesUtil';
import { missingCaseError } from '../util/missingCaseError';
import { Button, ButtonVariant } from './Button';
import type { PreferencesBackupPage } from '../types/PreferencesBackupPage';
import { Page } from './Preferences';
import { I18n } from './I18n';
import { PreferencesLocalBackups } from './PreferencesLocalBackups';
import type { ShowToastAction } from '../state/ducks/toast';
export const SIGNAL_BACKUPS_LEARN_MORE_URL =
'https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages';
export function PreferencesBackups({
cloudBackupStatus,
accountEntropyPool,
backupKeyViewed,
backupSubscriptionStatus,
cloudBackupStatus,
i18n,
locale,
localBackupFolder,
onBackupKeyViewedChange,
pickLocalBackupFolder,
page,
setPage,
showToast,
}: {
cloudBackupStatus?: BackupStatusType;
accountEntropyPool: string | undefined;
backupKeyViewed: boolean;
backupSubscriptionStatus?: BackupsSubscriptionType;
cloudBackupStatus?: BackupStatusType;
localBackupFolder: string | undefined;
i18n: LocalizerType;
locale: string;
onBackupKeyViewedChange: (keyViewed: boolean) => void;
page: PreferencesBackupPage;
pickLocalBackupFolder: () => Promise<string | undefined>;
setPage: (page: PreferencesBackupPage) => void;
showToast: ShowToastAction;
}): JSX.Element {
if (page === Page.BackupsDetails) {
return (
<BackupsDetailsPage
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={locale}
/>
);
}
if (
page === Page.LocalBackups ||
page === Page.LocalBackupsKeyReference ||
page === Page.LocalBackupsSetupFolder ||
page === Page.LocalBackupsSetupKey
) {
return (
<PreferencesLocalBackups
accountEntropyPool={accountEntropyPool}
backupKeyViewed={backupKeyViewed}
i18n={i18n}
localBackupFolder={localBackupFolder}
onBackupKeyViewedChange={onBackupKeyViewedChange}
page={page}
pickLocalBackupFolder={pickLocalBackupFolder}
setPage={setPage}
showToast={showToast}
/>
);
}
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
<a href={SIGNAL_BACKUPS_LEARN_MORE_URL} rel="noreferrer" target="_blank">
{parts}
</a>
);
const isLocalBackupsSetup = localBackupFolder && backupKeyViewed;
return (
<>
<div className="Preferences--backups-summary__container">
{getBackupsSubscriptionSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
<div className="Preferences__padding">
<div className="Preferences__description Preferences__description--medium">
{i18n('icu:Preferences--backup-section-description')}
</div>
</div>
{cloudBackupStatus ? (
<SettingsRow
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus.createdAt ? (
<div className="Preferences--backup-details__row">
<label>{i18n('icu:Preferences--backup-created-at__label')}</label>
<div
id="Preferences--backup-details__value"
className="Preferences--backup-details__value"
{backupSubscriptionStatus ? (
<SettingsRow className="Preferences--BackupsRow">
<Control
icon="Preferences__BackupsIcon"
left={
<label>
{i18n('icu:Preferences--signal-backups')}{' '}
<div className="Preferences__description">
{renderBackupsSubscriptionSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
</div>
</label>
}
right={
<Button
onClick={() => setPage(Page.BackupsDetails)}
variant={ButtonVariant.Secondary}
>
{/* TODO (DESKTOP-8509) */}
{i18n('icu:Preferences--backup-created-by-phone')}
<span className="Preferences--backup-details__value-divider" />
{formatTimestamp(cloudBackupStatus.createdAt, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
) : null}
{cloudBackupStatus.mediaSize != null ||
cloudBackupStatus.protoSize != null}
<div className="Preferences--backup-details__row">
{i18n('icu:Preferences__button--manage')}
</Button>
}
/>
</SettingsRow>
) : (
<SettingsRow className="Preferences--BackupsRow">
<Control
icon="Preferences__BackupsIcon"
left={
<label>
{i18n('icu:Preferences--signal-backups')}{' '}
<div className="Preferences--backup-details__value">
<I18n
id="icu:Preferences--signal-backups-off-description"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</div>
</label>
}
right={null}
/>
</SettingsRow>
)}
<SettingsRow
className="Preferences--BackupsRow"
title={i18n('icu:Preferences__backup-other-ways')}
>
<Control
icon="Preferences__LocalBackupsIcon"
left={
<label>
{i18n('icu:Preferences--backup-size__label')}{' '}
<div className="Preferences--backup-details__value">
{formatFileSize(
(cloudBackupStatus.mediaSize ?? 0) +
(cloudBackupStatus.protoSize ?? 0)
)}
{i18n('icu:Preferences__local-backups')}{' '}
<div className="Preferences__description">
{isLocalBackupsSetup
? null
: i18n('icu:Preferences--local-backups-off-description')}
</div>
</label>
</div>
</SettingsRow>
) : null}
}
right={
<Button
onClick={() => setPage(Page.LocalBackups)}
variant={ButtonVariant.Secondary}
>
{isLocalBackupsSetup
? i18n('icu:Preferences__button--manage')
: i18n('icu:Preferences__button--set-up')}
</Button>
}
/>
</SettingsRow>
</>
);
}
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 (
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-media-plan__description')}
</div>
<div className="Preferences--backups-summary__content">
{getSubscriptionDetails({ i18n, locale, subscriptionStatus })}
</div>
</div>
</div>
);
case 'free':
return (
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-messages-plan__description', {
mediaDayCount:
subscriptionStatus.mediaIncludedInBackupDurationDays,
})}
</div>
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-messages-plan__cost-description')}
</div>
</div>
</div>
);
case 'not-found':
case 'expired':
return (
<div className="Preferences--backups-summary__status-container ">
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-plan-not-found__description')}
</div>
</div>
);
default:
throw missingCaseError(status);
}
}
function BackupsDetailsPage({
cloudBackupStatus,
backupSubscriptionStatus,
i18n,
locale,
}: {
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
locale: string;
}): JSX.Element {
return (
<>
<div className="Preferences--backups-summary__container">
{renderBackupsSubscriptionDetails({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
</div>
{cloudBackupStatus ? (
<SettingsRow
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus.createdAt ? (
<div className="Preferences--backup-details__row">
<label>{i18n('icu:Preferences--backup-created-at__label')}</label>
<div
id="Preferences--backup-details__value"
className="Preferences--backup-details__value"
>
{/* TODO (DESKTOP-8509) */}
{i18n('icu:Preferences--backup-created-by-phone')}
<span className="Preferences--backup-details__value-divider" />
{formatTimestamp(cloudBackupStatus.createdAt, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
) : null}
</SettingsRow>
) : null}
</>
);
}

View file

@ -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<string | undefined>;
setPage: (page: PreferencesBackupPage) => void;
showToast: ShowToastAction;
}): JSX.Element {
if (!localBackupFolder) {
return (
<LocalBackupsSetupFolderPicker
i18n={i18n}
pickLocalBackupFolder={pickLocalBackupFolder}
/>
);
}
const isReferencingBackupKey = page === Page.LocalBackupsKeyReference;
if (!backupKeyViewed || isReferencingBackupKey) {
strictAssert(accountEntropyPool, 'AEP is required for backup key viewer');
return (
<LocalBackupsBackupKeyViewer
accountEntropyPool={accountEntropyPool}
i18n={i18n}
isReferencing={isReferencingBackupKey}
onBackupKeyViewed={() => {
if (backupKeyViewed) {
setPage(Page.LocalBackups);
} else {
onBackupKeyViewedChange(true);
}
}}
showToast={showToast}
/>
);
}
const learnMoreLink = (parts: Array<string | JSX.Element>) => (
<a href={SIGNAL_BACKUPS_LEARN_MORE_URL} rel="noreferrer" target="_blank">
{parts}
</a>
);
return (
<>
<div className="Preferences__padding">
<div className="Preferences__description Preferences__description--medium">
{i18n('icu:Preferences__local-backups-section__description')}
</div>
</div>
<SettingsRow className="Preferences--BackupsRow">
<Control
left={
<label>
{i18n('icu:Preferences__local-backups-folder')}
<div className="Preferences__description">
{localBackupFolder}
</div>
</label>
}
right={
<Button
onClick={pickLocalBackupFolder}
variant={ButtonVariant.Secondary}
>
{i18n('icu:Preferences__local-backups-folder__change')}
</Button>
}
/>
<Control
left={
<label>
{i18n('icu:Preferences__backup-key')}
<div className="Preferences__description">
{i18n('icu:Preferences__backup-key-description')}
</div>
</label>
}
right={
<Button
onClick={() => setPage(Page.LocalBackupsKeyReference)}
variant={ButtonVariant.Secondary}
>
{i18n('icu:Preferences__view-key')}
</Button>
}
/>
</SettingsRow>
<SettingsRow className="Preferences--BackupsRow">
<div className="Preferences__padding">
<div className="Preferences__description Preferences__description--medium">
<I18n
id="icu:Preferences--local-backups-restore-info"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
</div>
</div>
</SettingsRow>
</>
);
}
function LocalBackupsSetupFolderPicker({
i18n,
pickLocalBackupFolder,
}: {
i18n: LocalizerType;
pickLocalBackupFolder: () => Promise<string | undefined>;
}): JSX.Element {
return (
<div className="Preferences--LocalBackupsSetupScreen Preferences__padding">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-folder" />
<legend className="Preferences--LocalBackupsSetupScreenHeader">
{i18n('icu:Preferences--local-backups-setup-folder')}
</legend>
<div className="Preferences--LocalBackupsSetupScreenBody Preferences--LocalBackupsSetupScreenBody--folder">
{i18n('icu:Preferences--local-backups-setup-folder-description')}
</div>
<Button onClick={pickLocalBackupFolder} variant={ButtonVariant.Primary}>
{i18n('icu:Preferences__button--choose-folder')}
</Button>
</div>
</div>
);
}
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<boolean>(false);
const [step, setStep] = useState<BackupKeyStep>(
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<string | JSX.Element>) => (
<a href={SIGNAL_BACKUPS_LEARN_MORE_URL} rel="noreferrer" target="_blank">
{parts}
</a>
);
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 = (
<I18n
id="icu:Preferences--local-backups-record-backup-key-description"
i18n={i18n}
components={{
learnMoreLink,
}}
/>
);
if (step === 'view') {
footerRight = (
<Button
className="Preferences--LocalBackupsSetupScreenFooterButton"
onClick={() => setStep('confirm')}
variant={ButtonVariant.Primary}
>
{i18n('icu:Preferences--local-backups-setup-next')}
</Button>
);
} else {
footerRight = (
<Button
className="Preferences--LocalBackupsSetupScreenFooterButton"
onClick={onBackupKeyViewed}
variant={ButtonVariant.Primary}
>
{i18n('icu:Preferences--local-backups-view-backup-key-done')}
</Button>
);
}
} else {
title = i18n('icu:Preferences--local-backups-confirm-backup-key');
description = i18n(
'icu:Preferences--local-backups-confirm-backup-key-description'
);
footerLeft = (
<button
className="Preferences--LocalBackupsSetupScreenFooterSeeKeyButton"
onClick={() => setStep('view')}
type="button"
>
{i18n('icu:Preferences--local-backups-see-backup-key-again')}
</button>
);
footerRight = (
<Button
className="Preferences--LocalBackupsSetupScreenFooterButton"
disabled={!isBackupKeyConfirmed}
onClick={() => setStep('caution')}
variant={ButtonVariant.Primary}
>
{i18n('icu:Preferences--local-backups-setup-next')}
</Button>
);
}
return (
<div className="Preferences--LocalBackupsSetupScreen Preferences__settings-pane-content--with-footer Preferences__padding">
{step === 'caution' && (
<Modal
i18n={i18n}
modalName="CallingAdhocCallInfo.UnknownContactInfo"
moduleClassName="Preferences--LocalBackupsConfirmKeyModal"
modalFooter={
<Button
className="Preferences--LocalBackupsConfirmKeyModalButton"
onClick={onBackupKeyViewed}
>
{i18n(
'icu:Preferences__local-backups-confirm-key-modal-continue'
)}
</Button>
}
onClose={() => setStep('confirm')}
padded={false}
>
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-key" />
<legend className="Preferences--LocalBackupsConfirmKeyModalTitle">
{i18n('icu:Preferences__local-backups-confirm-key-modal-title')}
</legend>
<div className="Preferences--LocalBackupsConfirmKeyModalBody">
{i18n('icu:Preferences__local-backups-confirm-key-modal-body')}
</div>
</Modal>
)}
<div className="Preferences--LocalBackupsSetupScreenPane Preferences--LocalBackupsSetupScreenPane-top">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<div className="Preferences--LocalBackupsSetupIcon Preferences--LocalBackupsSetupIcon-lock" />
<legend className="Preferences--LocalBackupsSetupScreenHeader">
{title}
</legend>
<div className="Preferences--LocalBackupsSetupScreenBody">
{description}
</div>
</div>
</div>
<div className="Preferences--LocalBackupsSetupScreenPane">
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<LocalBackupsBackupKeyTextarea
backupKey={backupKey}
i18n={i18n}
onValidate={(isValid: boolean) => setIsBackupKeyConfirmed(isValid)}
isStepViewOrReference={isStepViewOrReference}
/>
</div>
{isStepViewOrReference && (
<div className="Preferences--LocalBackupsSetupScreenPaneContent">
<Button
className="Preferences--LocalBackupsSetupScreenCopyButton"
onClick={onCopyBackupKey}
size={ButtonSize.Small}
variant={ButtonVariant.Secondary}
>
{i18n('icu:Preferences__local-backups-copy-key')}
</Button>
</div>
)}
</div>
<div className="Preferences--LocalBackupsSetupScreenPane Preferences--LocalBackupsSetupScreenPane-footer">
<div className="Preferences--LocalBackupsSetupScreenFooterSection">
{footerLeft}
</div>
<div className="Preferences--LocalBackupsSetupScreenFooterSection Preferences--LocalBackupsSetupScreenFooterSection-right">
{footerRight}
</div>
</div>
</div>
);
}
function LocalBackupsBackupKeyTextarea({
backupKey,
i18n,
onValidate,
isStepViewOrReference,
}: {
backupKey: string;
i18n: LocalizerType;
onValidate: (isValid: boolean) => void;
isStepViewOrReference: boolean;
}): JSX.Element {
const backupKeyTextareaRef = useRef<HTMLTextAreaElement>(null);
const [backupKeyInput, setBackupKeyInput] = useState<string>('');
useEffect(() => {
if (backupKeyTextareaRef.current) {
backupKeyTextareaRef.current.focus();
}
}, [backupKeyTextareaRef, isStepViewOrReference]);
const backupKeyNoSpaces = React.useMemo(() => {
return backupKey.replace(/\s/g, '');
}, [backupKey]);
const handleTextareaChange = useCallback(
(ev: ChangeEvent<HTMLTextAreaElement>) => {
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 (
<textarea
aria-label={i18n('icu:Preferences--local-backups-backup-key-text-box')}
className="Preferences--LocalBackupsBackupKey"
cols={20}
dir="ltr"
rows={4}
maxLength={79}
onChange={isStepViewOrReference ? noop : handleTextareaChange}
placeholder={i18n('icu:Preferences--local-backups-enter-backup-key')}
readOnly={isStepViewOrReference}
ref={backupKeyTextareaRef}
spellCheck="false"
value={isStepViewOrReference ? backupKey : backupKeyInput}
/>
);
}

View file

@ -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:

View file

@ -225,6 +225,14 @@ export function renderToast({
);
}
if (toastType === ToastType.CopiedBackupKey) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>
{i18n('icu:Preferences__local-backups-copied-key')}
</Toast>
);
}
if (toastType === ToastType.CopiedCallLink) {
return (
<Toast onClose={hideToast} timeout={3 * SECOND}>

View file

@ -1115,6 +1115,18 @@ export class BackupsService {
getCachedCloudBackupStatus(): BackupStatusType | undefined {
return window.storage.get('cloudBackupStatus');
}
async pickLocalBackupFolder(): Promise<string | undefined> {
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();

View file

@ -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 (
<StrictMode>
<Preferences
accountEntropyPool={accountEntropyPool}
addCustomColor={addCustomColor}
autoDownloadAttachment={autoDownloadAttachment}
availableCameras={availableCameras}
@ -659,7 +674,9 @@ export function SmartPreferences(): JSX.Element | null {
availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers}
backupFeatureEnabled={backupFeatureEnabled}
backupKeyViewed={backupKeyViewed}
backupSubscriptionStatus={backupSubscriptionStatus}
backupLocalBackupsEnabled={backupLocalBackupsEnabled}
badge={badge}
blockedCount={blockedCount}
cloudBackupStatus={cloudBackupStatus}
@ -720,6 +737,7 @@ export function SmartPreferences(): JSX.Element | null {
isInternalUser={isInternalUser}
isUpdateDownloaded={isUpdateDownloaded}
lastSyncTime={lastSyncTime}
localBackupFolder={localBackupFolder}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
me={me}
@ -730,6 +748,7 @@ export function SmartPreferences(): JSX.Element | null {
onAutoDownloadAttachmentChange={onAutoDownloadAttachmentChange}
onAutoDownloadUpdateChange={onAutoDownloadUpdateChange}
onAutoLaunchChange={onAutoLaunchChange}
onBackupKeyViewedChange={onBackupKeyViewedChange}
onCallNotificationsChange={onCallNotificationsChange}
onCallRingtoneNotificationChange={onCallRingtoneNotificationChange}
onContentProtectionChange={onContentProtectionChange}
@ -766,6 +785,7 @@ export function SmartPreferences(): JSX.Element | null {
onZoomFactorChange={onZoomFactorChange}
otherTabsUnreadStats={otherTabsUnreadStats}
page={page}
pickLocalBackupFolder={pickLocalBackupFolder}
preferredSystemLocales={preferredSystemLocales}
preferredWidthFromStorage={preferredWidthFromStorage}
refreshCloudBackupStatus={refreshCloudBackupStatus}

View file

@ -0,0 +1,25 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { Page } from '../components/Preferences';
// Should be in sync with isBackupPage()
export type PreferencesBackupPage =
| Page.Backups
| Page.BackupsDetails
| Page.LocalBackups
| Page.LocalBackupsKeyReference
| Page.LocalBackupsSetupFolder
| Page.LocalBackupsSetupKey;
// Should be in sync with PreferencesBackupPage
export function isBackupPage(page: Page): page is PreferencesBackupPage {
return (
page === Page.Backups ||
page === Page.BackupsDetails ||
page === Page.LocalBackups ||
page === Page.LocalBackupsSetupFolder ||
page === Page.LocalBackupsSetupKey ||
page === Page.LocalBackupsKeyReference
);
}

View file

@ -231,6 +231,9 @@ export type StorageAccessType = {
cloudBackupStatus: BackupStatusType | undefined;
backupSubscriptionStatus: BackupsSubscriptionType;
backupKeyViewed: boolean;
localBackupFolder: string | undefined;
// If true Desktop message history was restored from backup
isRestoredFromBackup: boolean;

View file

@ -23,6 +23,7 @@ export enum ToastType {
ConversationMarkedUnread = 'ConversationMarkedUnread',
ConversationRemoved = 'ConversationRemoved',
ConversationUnarchived = 'ConversationUnarchived',
CopiedBackupKey = 'CopiedBackupKey',
CopiedCallLink = 'CopiedCallLink',
CopiedUsername = 'CopiedUsername',
CopiedUsernameLink = 'CopiedUsernameLink',
@ -110,6 +111,7 @@ export type AnyToast =
| { toastType: ToastType.ConversationMarkedUnread }
| { toastType: ToastType.ConversationRemoved; parameters: { title: string } }
| { toastType: ToastType.ConversationUnarchived }
| { toastType: ToastType.CopiedBackupKey }
| { toastType: ToastType.CopiedCallLink }
| { toastType: ToastType.CopiedUsername }
| { toastType: ToastType.CopiedUsernameLink }

View file

@ -22,3 +22,22 @@ export function isLocalBackupsEnabled(): boolean {
return false;
}
export function isLocalBackupsEnabledForRedux(
config: Pick<RemoteConfig.ConfigMapType, 'desktop.internalUser'> | 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;
}

View file

@ -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<HTMLTextAreaElement>(null);",
"reasonCategory": "usageTrusted",
"updated": "2025-05-30T22:48:14.420Z",
"reasonDetail": "For focusing the settings backup key viewer textarea"
}
]