Show backup status in Settings window

This commit is contained in:
trevor-signal 2025-04-02 14:57:29 -04:00 committed by GitHub
commit aba0e028d4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1136 additions and 191 deletions

View file

@ -6587,6 +6587,10 @@
"messageformat": "Voice messages and stickers are always auto-downloaded.", "messageformat": "Voice messages and stickers are always auto-downloaded.",
"description": "Additional clarification for how media auto-download will behave" "description": "Additional clarification for how media auto-download will behave"
}, },
"icu:Preferences__button--backups": {
"messageformat": "Backups",
"description": "Button to switch the settings view to control message & media backups"
},
"icu:Preferences--lastSynced": { "icu:Preferences--lastSynced": {
"messageformat": "Last import at {date} {time}", "messageformat": "Last import at {date} {time}",
"description": "Label for date and time of last sync operation" "description": "Label for date and time of last sync operation"
@ -6627,6 +6631,66 @@
"messageformat": "Blocked", "messageformat": "Blocked",
"description": "Label for blocked contacts setting" "description": "Label for blocked contacts setting"
}, },
"icu:Preferences--backup-details__header": {
"messageformat": "Backup details",
"description": "Section title for info on your current backup (created time & size)"
},
"icu:Preferences--backup-media-plan__description": {
"messageformat": "Text + all media backup",
"description": "Description of a backup plan that backups all of their messages (text) and media"
},
"icu:Preferences--backup-plan-not-found__description": {
"messageformat": "Your subscription was not found. Renew to continue using Signal Backups.",
"description": "Description when a backup subscription used to exist but is not active"
},
"icu:Preferences--backup-messages-plan__description": {
"messageformat": "Text + {mediaDayCount, plural, one {# day} other {# days}} media backup",
"description": "Description of a backup plan that backups all of their messages (text) and recent ~45 days of media"
},
"icu:Preferences--backup-messages-plan__cost-description": {
"messageformat": "Your backup plan is free",
"description": "Description of the cost of the user's (free!) backup plan"
},
"icu:Preferences--backup-plan__renewal-date": {
"messageformat": "Renews {date}",
"description": "Text describing the date at which the backup plan renews"
},
"icu:Preferences--backup-plan__expiry-date": {
"messageformat": "Expires {date}",
"description": "Text describing the date at which the backup plan expires"
},
"icu:Preferences--backup-plan__canceled": {
"messageformat": "Subscription canceled",
"description": "Description of plan when it has been canceled (i.e. not going to renew but still active)"
},
"icu:Preferences--backup-media-plan__note": {
"messageformat": "You can manage or cancel your Signal Backups subscription on your phone.",
"description": "Note next to backups plan summary"
},
"icu:Preferences--backup-messages-plan__note": {
"messageformat": "You can manage or upgrade Signal Backups on your phone.",
"description": "Note next to backups plan summary"
},
"icu:Preferences--backup-plan__not-found": {
"messageformat": "Your subscription was not found. Renew to continue using Signal Backups.",
"description": "Shown if if we could not find the user's subscription"
},
"icu:Preferences--backup-plan__not-found__note": {
"messageformat": "You can manage or renew your Signal Backups subscription on your phone.",
"description": "Note next to backups plan summary if we could not find their subscription"
},
"icu:Preferences--backup-created-at__label": {
"messageformat": "Last backup",
"description": "Label for the date that the last backup was created for this user"
},
"icu:Preferences--backup-created-by-phone": {
"messageformat": "Your phone",
"description": "Label for the primary device (your phone) that made the most recent cloud backup"
},
"icu:Preferences--backup-size__label": {
"messageformat": "Backup size",
"description": "Label for the size (e.g. 1.4 GB) of this user's backup"
},
"icu:Preferences--blocked-count": { "icu:Preferences--blocked-count": {
"messageformat": "{num, plural, one {# contact} other {# contacts}}", "messageformat": "{num, plural, one {# contact} other {# contacts}}",
"description": "Number of contacts blocked plural" "description": "Number of contacts blocked plural"

View file

@ -0,0 +1,9 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.6632 37.7164C42.2623 38.241 42.335 39.155 41.8186 39.7612L41.804 39.7783C38.75 43.3628 34.6106 46.0605 29.7344 47.2909C24.8583 48.5214 19.9544 48.1057 15.5991 46.3909V46.3909C14.852 46.0967 14.4815 45.2555 14.7688 44.5058L14.7793 44.4784C15.065 43.7327 15.904 43.3631 16.6471 43.6557V43.6557C20.4636 45.1584 24.7577 45.5229 29.0352 44.4435C33.3128 43.3641 36.9372 41.0014 39.6134 37.8603V37.8603C40.1338 37.2496 41.0595 37.1879 41.6632 37.7164V37.7164Z" fill="#9CA4EF" style="fill:#9CA4EF;fill:color(display-p3 0.6115 0.6440 0.9362);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 42C20.6334 42 17.4827 41.0758 14.7878 39.4672V36.4776C14.7878 34.5535 13.228 32.9938 11.3039 32.9938C10.4027 32.9938 9.58147 33.3359 8.96295 33.8974C7.09007 31.0577 6 27.6562 6 24C6 14.0589 14.0589 6 24 6C33.9411 6 42 14.0589 42 24C42 33.9411 33.9411 42 24 42Z" fill="#D2D8FE" style="fill:#D2D8FE;fill:color(display-p3 0.8235 0.8471 0.9961);fill-opacity:1;"/>
<path d="M21.7344 14.6949L21.3405 28.6994C21.3393 28.7264 21.3387 28.7535 21.3387 28.7806C21.3387 29.7213 22.0536 30.4839 22.9355 30.4839C22.9708 30.4839 23.0061 30.4826 23.0411 30.4801L32.4275 30.063C33.072 30.0343 33.5807 29.4687 33.5807 28.7806C33.5807 28.0926 33.072 27.5269 32.4275 27.4983L24.4868 27.1454L24.1366 14.6949C24.1171 14.0023 23.5851 13.4516 22.9355 13.4516C22.2859 13.4516 21.7539 14.0023 21.7344 14.6949Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7783 6.196C43.3628 9.24998 46.0605 13.3894 47.2909 18.2656C48.5214 23.1417 48.1057 28.0456 46.3909 32.4009V32.4009C46.0967 33.148 45.2555 33.5185 44.5057 33.2312L44.4784 33.2208C43.7327 32.935 43.3631 32.096 43.6557 31.3529V31.3529C45.1584 27.5364 45.5229 23.2423 44.4435 18.9648C43.3641 14.6872 41.0014 11.0628 37.8603 8.38663V8.38663C37.2496 7.86624 37.1879 6.94051 37.7164 6.33679V6.33679C38.241 5.73768 39.155 5.66498 39.7612 6.1814L39.7783 6.196Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.2208 3.52162C32.935 4.26734 32.096 4.63692 31.3529 4.34434V4.34434C27.5364 2.84162 23.2424 2.47708 18.9648 3.55649C14.6872 4.63589 11.0628 6.99857 8.38663 10.1397V10.1397C7.86624 10.7504 6.94051 10.8121 6.33679 10.2836V10.2836C5.73768 9.75902 5.66498 8.84496 6.1814 8.23883L6.196 8.22169C9.24998 4.63717 13.3894 1.93954 18.2656 0.709095C23.1417 -0.521353 28.0456 -0.105728 32.4009 1.60914V1.60914C33.148 1.9033 33.5185 2.7445 33.2312 3.49425L33.2208 3.52162Z" fill="#9CA4EF" style="fill:#9CA4EF;fill:color(display-p3 0.6115 0.6440 0.9362);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8298 35.0029C11.4761 35.0029 12 35.5292 12 36.1783V41.5654C12 42.2146 11.4761 42.7408 10.8298 42.7408H4.92984C4.28353 42.7408 3.7596 42.2146 3.7596 41.5654C3.7596 40.9163 4.28353 40.3901 4.92984 40.3901H9.65953V36.1783C9.65953 35.5292 10.1835 35.0029 10.8298 35.0029Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49754 14.3501C4.2506 14.6439 4.62397 15.4952 4.33146 16.2516C2.8332 20.1259 2.46974 24.485 3.54594 28.8274C4.62215 33.1698 6.97783 36.8491 10.1096 39.5658L11.2166 40.5262L9.30437 42.75L8.19732 41.7897C4.62343 38.6894 1.93379 34.4872 0.706992 29.5372C-0.519808 24.5872 -0.105413 19.6089 1.60437 15.1877C1.89687 14.4313 2.74447 14.0563 3.49754 14.3501Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,9 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.6632 37.7164C42.2623 38.241 42.335 39.155 41.8186 39.7612L41.804 39.7783C38.75 43.3628 34.6106 46.0605 29.7344 47.2909C24.8583 48.5214 19.9544 48.1057 15.5991 46.3909V46.3909C14.852 46.0967 14.4815 45.2555 14.7688 44.5058L14.7793 44.4784C15.065 43.7327 15.904 43.3631 16.6471 43.6557V43.6557C20.4636 45.1584 24.7577 45.5229 29.0352 44.4435C33.3128 43.3641 36.9372 41.0014 39.6134 37.8603V37.8603C40.1338 37.2496 41.0595 37.1879 41.6632 37.7164V37.7164Z" fill="#A0A7FE" style="fill:#A0A7FE;fill:color(display-p3 0.6275 0.6549 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 42C20.6334 42 17.4827 41.0758 14.7878 39.4672V36.4776C14.7878 34.5535 13.228 32.9938 11.3039 32.9938C10.4027 32.9938 9.58147 33.3359 8.96295 33.8974C7.09007 31.0577 6 27.6562 6 24C6 14.0589 14.0589 6 24 6C33.9411 6 42 14.0589 42 24C42 33.9411 33.9411 42 24 42Z" fill="#D2D8FE" style="fill:#D2D8FE;fill:color(display-p3 0.8235 0.8471 0.9961);fill-opacity:1;"/>
<path d="M21.7344 14.6949L21.3405 28.6994C21.3393 28.7264 21.3387 28.7535 21.3387 28.7806C21.3387 29.7213 22.0536 30.4839 22.9355 30.4839C22.9708 30.4839 23.0061 30.4826 23.0411 30.4801L32.4275 30.063C33.072 30.0343 33.5807 29.4687 33.5807 28.7806C33.5807 28.0926 33.072 27.5269 32.4275 27.4983L24.4868 27.1454L24.1366 14.6949C24.1171 14.0023 23.5851 13.4516 22.9355 13.4516C22.2859 13.4516 21.7539 14.0023 21.7344 14.6949Z" fill="#3C46FE" style="fill:#3C46FE;fill:color(display-p3 0.2353 0.2745 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.7783 6.196C43.3628 9.24998 46.0605 13.3894 47.2909 18.2656C48.5214 23.1417 48.1057 28.0456 46.3909 32.4009V32.4009C46.0967 33.148 45.2555 33.5185 44.5057 33.2312L44.4784 33.2208C43.7327 32.935 43.3631 32.096 43.6557 31.3529V31.3529C45.1584 27.5364 45.5229 23.2423 44.4435 18.9648C43.3641 14.6872 41.0014 11.0628 37.8603 8.38663V8.38663C37.2496 7.86624 37.1879 6.94051 37.7164 6.33679V6.33679C38.241 5.73768 39.155 5.66498 39.7612 6.1814L39.7783 6.196Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.2208 3.52162C32.935 4.26734 32.096 4.63692 31.3529 4.34434V4.34434C27.5364 2.84162 23.2424 2.47708 18.9648 3.55649C14.6872 4.63589 11.0628 6.99857 8.38663 10.1397V10.1397C7.86624 10.7504 6.94051 10.8121 6.33679 10.2836V10.2836C5.73768 9.75902 5.66498 8.84496 6.1814 8.23883L6.196 8.22169C9.24998 4.63717 13.3894 1.93954 18.2656 0.709095C23.1417 -0.521353 28.0456 -0.105728 32.4009 1.60914V1.60914C33.148 1.9033 33.5185 2.7445 33.2312 3.49425L33.2208 3.52162Z" fill="#A0A7FE" style="fill:#A0A7FE;fill:color(display-p3 0.6275 0.6549 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8298 35.0029C11.4761 35.0029 12 35.5292 12 36.1783V41.5654C12 42.2146 11.4761 42.7408 10.8298 42.7408H4.92984C4.28353 42.7408 3.7596 42.2146 3.7596 41.5654C3.7596 40.9163 4.28353 40.3901 4.92984 40.3901H9.65953V36.1783C9.65953 35.5292 10.1835 35.0029 10.8298 35.0029Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49754 14.3501C4.2506 14.6439 4.62397 15.4952 4.33146 16.2516C2.8332 20.1259 2.46974 24.485 3.54594 28.8274C4.62215 33.1698 6.97783 36.8491 10.1096 39.5658L11.2166 40.5262L9.30437 42.75L8.19732 41.7897C4.62343 38.6894 1.93379 34.4872 0.706992 29.5372C-0.519808 24.5872 -0.105413 19.6089 1.60437 15.1877C1.89687 14.4313 2.74447 14.0563 3.49754 14.3501Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,9 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.8897 37.9412C42.4982 38.4532 42.5649 39.366 42.0374 39.961V39.961C38.9298 43.4659 34.7178 46.1036 29.7561 47.3067C24.7944 48.5098 19.8044 48.1034 15.3728 46.4266V46.4266C14.6199 46.1418 14.2445 45.294 14.5426 44.5463V44.5463C14.8334 43.8172 15.6548 43.4554 16.389 43.7332L16.4392 43.7522C20.3226 45.2215 24.692 45.578 29.0446 44.5225C33.3973 43.4671 37.0852 41.157 39.8083 38.0857L39.8297 38.0615C40.3535 37.4709 41.2525 37.4051 41.8565 37.9133L41.8897 37.9412Z" fill="#9CA4EF" style="fill:#9CA4EF;fill:color(display-p3 0.6115 0.6440 0.9362);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 42C20.6334 42 17.4827 41.0758 14.7878 39.4672V36.4776C14.7878 34.5535 13.228 32.9938 11.3039 32.9938C10.4027 32.9938 9.58147 33.3359 8.96295 33.8974C7.09007 31.0577 6 27.6562 6 24C6 14.0589 14.0589 6 24 6C33.9411 6 42 14.0589 42 24C42 33.9411 33.9411 42 24 42Z" fill="#D2D8FE" style="fill:#D2D8FE;fill:color(display-p3 0.8235 0.8471 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.7145 16.6128C29.0838 15.9945 29.851 15.8141 30.428 16.2098C31.0051 16.6055 31.1735 17.4275 30.8042 18.0457L23.4321 30.3874C23.2153 30.7503 22.8478 30.9781 22.4461 30.9985C22.0443 31.0189 21.6583 30.8294 21.4104 30.49L17.2637 24.8129C16.8414 24.2348 16.9365 23.3995 17.476 22.9471C18.0155 22.4947 18.7952 22.5966 19.2174 23.1746L22.2858 27.3753L28.7145 16.6128Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.961 5.9626C43.4659 9.07016 46.1036 13.2822 47.3067 18.2439C48.5098 23.2056 48.1034 28.1956 46.4266 32.6272V32.6272C46.1418 33.3801 45.294 33.7555 44.5463 33.4574V33.4574C43.8172 33.1666 43.4554 32.3452 43.7332 31.611L43.7522 31.5608C45.2215 27.6774 45.578 23.308 44.5225 18.9554C43.4671 14.6027 41.157 10.9148 38.0857 8.19166L38.0615 8.17026C37.4709 7.64654 37.4051 6.74752 37.9133 6.14345L37.9412 6.11028C38.4532 5.50181 39.366 5.43506 39.961 5.9626V5.9626Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.4574 3.45366C33.1666 4.18284 32.3452 4.54457 31.611 4.26678L31.5608 4.2478C27.6774 2.77847 23.308 2.42204 18.9554 3.47745C14.6027 4.53287 10.9148 6.84305 8.19165 9.91433L8.17026 9.93846C7.64654 10.5291 6.74752 10.5949 6.14345 10.0867L6.11028 10.0588C5.50181 9.54684 5.43506 8.63397 5.9626 8.03899V8.03899C9.07016 4.53412 13.2822 1.89644 18.2439 0.693337C23.2056 -0.509767 28.1956 -0.103378 32.6272 1.57338V1.57338C33.3801 1.85822 33.7555 2.70599 33.4574 3.45366V3.45366Z" fill="#9CA4EF" style="fill:#9CA4EF;fill:color(display-p3 0.6115 0.6440 0.9362);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8298 35.117C11.4761 35.117 12 35.6525 12 36.313V41.7947C12 42.4552 11.4761 42.9906 10.8298 42.9906H4.92984C4.28353 42.9906 3.7596 42.4552 3.7596 41.7947C3.7596 41.1341 4.28353 40.5986 4.92984 40.5986H9.65953V36.313C9.65953 35.6525 10.1835 35.117 10.8298 35.117Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49754 14.1018C4.2506 14.4008 4.62397 15.267 4.33146 16.0367C2.8332 19.979 2.46974 24.4146 3.54594 28.8332C4.62215 33.2517 6.97783 36.9956 10.1096 39.76L11.2166 40.7372L9.30437 43L8.19732 42.0228C4.62343 38.8682 1.93379 34.5923 0.706992 29.5554C-0.519808 24.5185 -0.105413 19.4529 1.60437 14.9541C1.89687 14.1845 2.74447 13.8029 3.49754 14.1018Z" fill="#666EE5" style="fill:#666EE5;fill:color(display-p3 0.4004 0.4332 0.8982);fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -0,0 +1,9 @@
<svg width="48" height="48" viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M41.8897 37.9412C42.4982 38.4532 42.5649 39.366 42.0374 39.961V39.961C38.9298 43.4659 34.7178 46.1036 29.7561 47.3067C24.7944 48.5098 19.8044 48.1034 15.3728 46.4266V46.4266C14.6199 46.1418 14.2445 45.294 14.5426 44.5463V44.5463C14.8334 43.8172 15.6548 43.4554 16.389 43.7332L16.4392 43.7522C20.3226 45.2215 24.692 45.578 29.0446 44.5225C33.3973 43.4671 37.0852 41.157 39.8083 38.0857L39.8297 38.0615C40.3535 37.4709 41.2525 37.4051 41.8565 37.9133L41.8897 37.9412Z" fill="#A0A7FE" style="fill:#A0A7FE;fill:color(display-p3 0.6275 0.6549 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M24 42C20.6334 42 17.4827 41.0758 14.7878 39.4672V36.4776C14.7878 34.5535 13.228 32.9938 11.3039 32.9938C10.4027 32.9938 9.58147 33.3359 8.96295 33.8974C7.09007 31.0577 6 27.6562 6 24C6 14.0589 14.0589 6 24 6C33.9411 6 42 14.0589 42 24C42 33.9411 33.9411 42 24 42Z" fill="#D2D8FE" style="fill:#D2D8FE;fill:color(display-p3 0.8235 0.8471 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M28.7145 16.6128C29.0838 15.9945 29.851 15.8141 30.428 16.2098C31.0051 16.6055 31.1735 17.4275 30.8042 18.0457L23.4321 30.3874C23.2153 30.7503 22.8478 30.9781 22.4461 30.9985C22.0443 31.0189 21.6583 30.8294 21.4104 30.49L17.2637 24.8129C16.8414 24.2348 16.9365 23.3995 17.476 22.9471C18.0155 22.4947 18.7952 22.5966 19.2174 23.1746L22.2858 27.3753L28.7145 16.6128Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M39.961 5.9626C43.4659 9.07016 46.1036 13.2822 47.3067 18.2439C48.5098 23.2056 48.1034 28.1956 46.4266 32.6272V32.6272C46.1418 33.3801 45.294 33.7555 44.5463 33.4574V33.4574C43.8172 33.1666 43.4554 32.3452 43.7332 31.611L43.7522 31.5608C45.2215 27.6774 45.578 23.308 44.5225 18.9554C43.4671 14.6027 41.157 10.9148 38.0857 8.19166L38.0615 8.17026C37.4709 7.64654 37.4051 6.74752 37.9133 6.14345L37.9412 6.11028C38.4532 5.50181 39.366 5.43506 39.961 5.9626V5.9626Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M33.4574 3.45366C33.1666 4.18284 32.3452 4.54457 31.611 4.26678L31.5608 4.2478C27.6774 2.77847 23.308 2.42204 18.9554 3.47745C14.6027 4.53287 10.9148 6.84305 8.19165 9.91433L8.17026 9.93846C7.64654 10.5291 6.74752 10.5949 6.14345 10.0867L6.11028 10.0588C5.50181 9.54684 5.43506 8.63397 5.9626 8.03899V8.03899C9.07016 4.53412 13.2822 1.89644 18.2439 0.693337C23.2056 -0.509767 28.1956 -0.103378 32.6272 1.57338V1.57338C33.3801 1.85822 33.7555 2.70599 33.4574 3.45366V3.45366Z" fill="#A0A7FE" style="fill:#A0A7FE;fill:color(display-p3 0.6275 0.6549 0.9961);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M10.8298 35.117C11.4761 35.117 12 35.6525 12 36.313V41.7947C12 42.4552 11.4761 42.9906 10.8298 42.9906H4.92984C4.28353 42.9906 3.7596 42.4552 3.7596 41.7947C3.7596 41.1341 4.28353 40.5986 4.92984 40.5986H9.65953V36.313C9.65953 35.6525 10.1835 35.117 10.8298 35.117Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.49754 14.1018C4.2506 14.4008 4.62397 15.267 4.33146 16.0367C2.8332 19.979 2.46974 24.4146 3.54594 28.8332C4.62215 33.2517 6.97783 36.9956 10.1096 39.76L11.2166 40.7372L9.30437 43L8.19732 42.0228C4.62343 38.8682 1.93379 34.5923 0.706992 29.5554C-0.519808 24.5185 -0.105413 19.4529 1.60437 14.9541C1.89687 14.1845 2.74447 13.8029 3.49754 14.1018Z" fill="#3B45FD" style="fill:#3B45FD;fill:color(display-p3 0.2314 0.2706 0.9922);fill-opacity:1;"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View file

@ -15,6 +15,11 @@
} }
} }
$secondary-text-color: light-dark(
variables.$color-gray-60,
variables.$color-gray-25
);
.Preferences { .Preferences {
display: flex; display: flex;
overflow: hidden; overflow: hidden;
@ -111,6 +116,10 @@
&--data-usage { &--data-usage {
@include preferences-icon('../images/icons/v3/data/data.svg'); @include preferences-icon('../images/icons/v3/data/data.svg');
} }
&--backups {
@include preferences-icon('../images/icons/v3/backup/backup-bold.svg');
}
} }
&__settings-pane { &__settings-pane {
@ -140,6 +149,11 @@
border-color: variables.$color-gray-65; border-color: variables.$color-gray-65;
} }
&--backups {
border: none;
margin-bottom: 16px;
}
&--header { &--header {
flex-grow: 1; flex-grow: 1;
text-align: center; text-align: center;
@ -247,13 +261,7 @@
&__description { &__description {
@include mixins.font-subtitle; @include mixins.font-subtitle;
@include mixins.light-theme { color: $secondary-text-color;
color: variables.$color-gray-60;
}
@include mixins.dark-theme {
color: variables.$color-gray-25;
}
&--error { &--error {
color: variables.$color-accent-red !important; color: variables.$color-accent-red !important;
} }
@ -444,3 +452,90 @@
color: variables.$color-gray-25; color: variables.$color-gray-25;
} }
} }
.Preferences--backups-summary {
&__container {
background-color: light-dark(
variables.$color-gray-02,
variables.$color-gray-80
);
border-radius: 12px;
padding-block: 20px;
padding-inline: 16px;
margin-inline: 24px;
}
&__status-container {
display: flex;
justify-content: space-between;
}
&__type {
@include mixins.font-subtitle;
color: $secondary-text-color;
margin-block-end: 8px;
}
&__note {
@include mixins.font-subtitle;
color: $secondary-text-color;
margin-block-start: 12px;
}
&__canceled {
@include mixins.font-body-1-bold;
color: variables.$color-accent-red;
}
&__icon {
&--active {
&::after {
@include mixins.dark-theme() {
background-image: url('../images/icons/v3/backup/backups-subscribed-dark.svg');
}
@include mixins.light-theme() {
background-image: url('../images/icons/v3/backup/backups-subscribed-light.svg');
}
}
}
&--inactive {
&::after {
@include mixins.dark-theme() {
background-image: url('../images/icons/v3/backup/backups-logo-dark.svg');
}
@include mixins.light-theme() {
background-image: url('../images/icons/v3/backup/backups-logo-light.svg');
}
}
}
&::after {
& {
content: '';
margin-inline-start: 8px;
display: block;
height: 48px;
width: 48px;
}
}
}
}
.Preferences--backup-details {
margin-block-start: 30px;
legend {
margin-block-end: 10px;
}
&__row {
padding-block: 10px;
padding-inline: 24px;
}
&__value {
margin-block-start: 2px;
@include mixins.font-subtitle;
color: $secondary-text-color;
}
&__value-divider {
&::before {
content: '';
margin-inline: 4px;
}
}
}

View file

@ -40,7 +40,7 @@ import { isWindowDragElement } from './util/isWindowDragElement';
import { assertDev, strictAssert } from './util/assert'; import { assertDev, strictAssert } from './util/assert';
import { filter } from './util/iterables'; import { filter } from './util/iterables';
import { isNotNil } from './util/isNotNil'; import { isNotNil } from './util/isNotNil';
import { isBackupEnabled } from './util/isBackupEnabled'; import { isBackupFeatureEnabled } from './util/isBackupEnabled';
import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage'; import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage';
import { IdleDetector } from './IdleDetector'; import { IdleDetector } from './IdleDetector';
import { import {
@ -1949,7 +1949,7 @@ export async function startApp(): Promise<void> {
drop(window.Signal.Services.initializeGroupCredentialFetcher()); drop(window.Signal.Services.initializeGroupCredentialFetcher());
drop(AttachmentDownloadManager.start()); drop(AttachmentDownloadManager.start());
if (isBackupEnabled()) { if (isBackupFeatureEnabled()) {
backupsService.start(); backupsService.start();
drop(AttachmentBackupManager.start()); drop(AttachmentBackupManager.start());
} }

View file

@ -6,12 +6,12 @@ import React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { PropsType } from './Preferences'; import type { PropsType } from './Preferences';
import { Preferences } from './Preferences'; import { Page, Preferences } from './Preferences';
import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors'; import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors';
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
import { DurationInSeconds } from '../util/durations';
import { EmojiSkinTone } from './fun/data/emojis'; import { EmojiSkinTone } from './fun/data/emojis';
import { DAY, DurationInSeconds, WEEK } from '../util/durations';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
@ -76,6 +76,7 @@ export default {
availableLocales: ['en'], availableLocales: ['en'],
availableMicrophones, availableMicrophones,
availableSpeakers, availableSpeakers,
backupFeatureEnabled: false,
blockedCount: 0, blockedCount: 0,
customColors: {}, customColors: {},
defaultConversationColor: DEFAULT_CONVERSATION_COLOR, defaultConversationColor: DEFAULT_CONVERSATION_COLOR,
@ -177,6 +178,8 @@ export default {
onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'), onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'),
onWhoCanFindMeChange: action('onWhoCanFindMeChange'), onWhoCanFindMeChange: action('onWhoCanFindMeChange'),
onZoomFactorChange: action('onZoomFactorChange'), onZoomFactorChange: action('onZoomFactorChange'),
refreshCloudBackupStatus: action('refreshCloudBackupStatus'),
refreshBackupSubscriptionStatus: action('refreshBackupSubscriptionStatus'),
removeCustomColor: action('removeCustomColor'), removeCustomColor: action('removeCustomColor'),
removeCustomColorOnConversations: action( removeCustomColorOnConversations: action(
'removeCustomColorOnConversations' 'removeCustomColorOnConversations'
@ -220,3 +223,80 @@ PNPDiscoverabilityDisabled.args = {
whoCanSeeMe: PhoneNumberSharingMode.Nobody, whoCanSeeMe: PhoneNumberSharingMode.Nobody,
whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable, whoCanFindMe: PhoneNumberDiscoverability.NotDiscoverable,
}; };
export const BackupsPaidActive = Template.bind({});
BackupsPaidActive.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
},
backupSubscriptionStatus: {
status: 'active',
cost: {
amount: 22.99,
currencyCode: 'USD',
},
renewalDate: new Date(Date.now() + 20 * DAY),
},
};
export const BackupsPaidCancelled = Template.bind({});
BackupsPaidCancelled.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
},
backupSubscriptionStatus: {
status: 'pending-cancellation',
cost: {
amount: 22.99,
currencyCode: 'USD',
},
expiryDate: new Date(Date.now() + 20 * DAY),
},
};
export const BackupsFree = Template.bind({});
BackupsFree.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
backupSubscriptionStatus: {
status: 'free',
mediaIncludedInBackupDurationDays: 30,
},
};
export const BackupsOff = Template.bind({});
BackupsOff.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
};
export const BackupsSubscriptionNotFound = Template.bind({});
BackupsSubscriptionNotFound.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
backupSubscriptionStatus: {
status: 'not-found',
},
cloudBackupStatus: {
mediaSize: 539_249_410_039,
protoSize: 100_000_000,
createdAt: new Date(Date.now() - WEEK).getTime(),
},
};
export const BackupsSubscriptionExpired = Template.bind({});
BackupsSubscriptionExpired.args = {
initialPage: Page.Backups,
backupFeatureEnabled: true,
backupSubscriptionStatus: {
status: 'expired',
},
};

View file

@ -2,7 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { AudioDevice } from '@signalapp/ringrtc'; import type { AudioDevice } from '@signalapp/ringrtc';
import type { ReactNode } from 'react';
import React, { import React, {
useCallback, useCallback,
useEffect, useEffect,
@ -12,7 +11,6 @@ import React, {
} from 'react'; } from 'react';
import { noop, partition } from 'lodash'; import { noop, partition } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import { v4 as uuid } from 'uuid';
import * as LocaleMatcher from '@formatjs/intl-localematcher'; import * as LocaleMatcher from '@formatjs/intl-localematcher';
import type { MediaDeviceSettings } from '../types/Calling'; import type { MediaDeviceSettings } from '../types/Calling';
@ -41,10 +39,7 @@ import { Button, ButtonVariant } from './Button';
import { ChatColorPicker } from './ChatColorPicker'; import { ChatColorPicker } from './ChatColorPicker';
import { Checkbox } from './Checkbox'; import { Checkbox } from './Checkbox';
import { WidthBreakpoint } from './_util'; import { WidthBreakpoint } from './_util';
import {
CircleCheckbox,
Variant as CircleCheckboxVariant,
} from './CircleCheckbox';
import { ConfirmationDialog } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog';
import { DisappearingTimeDialog } from './DisappearingTimeDialog'; import { DisappearingTimeDialog } from './DisappearingTimeDialog';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
@ -70,6 +65,16 @@ import { assertDev } from '../util/assert';
import { I18n } from './I18n'; import { I18n } from './I18n';
import { FunSkinTonesList } from './fun/FunSkinTones'; import { FunSkinTonesList } from './fun/FunSkinTones';
import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis'; import { emojiParentKeyConstant, type EmojiSkinTone } from './fun/data/emojis';
import type {
BackupsSubscriptionType,
BackupStatusType,
} from '../types/backups';
import {
SettingsControl as Control,
SettingsRadio,
SettingsRow,
} from './PreferencesUtil';
import { PreferencesBackups } from './PreferencesBackups';
type CheckboxChangeHandlerType = (value: boolean) => unknown; type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown; type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
@ -77,7 +82,10 @@ type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
export type PropsDataType = { export type PropsDataType = {
// Settings // Settings
autoDownloadAttachment: AutoDownloadAttachmentType; autoDownloadAttachment: AutoDownloadAttachmentType;
backupFeatureEnabled: boolean;
blockedCount: number; blockedCount: number;
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
customColors: Record<string, CustomColorType>; customColors: Record<string, CustomColorType>;
defaultConversationColor: DefaultConversationColorType; defaultConversationColor: DefaultConversationColorType;
deviceName?: string; deviceName?: string;
@ -105,6 +113,7 @@ export type PropsDataType = {
hasStoriesDisabled: boolean; hasStoriesDisabled: boolean;
hasTextFormatting: boolean; hasTextFormatting: boolean;
hasTypingIndicators: boolean; hasTypingIndicators: boolean;
initialPage?: Page;
lastSyncTime?: number; lastSyncTime?: number;
notificationContent: NotificationSettingType; notificationContent: NotificationSettingType;
phoneNumber: string | undefined; phoneNumber: string | undefined;
@ -152,6 +161,8 @@ type PropsFunctionType = {
colorId: string colorId: string
) => Promise<Array<ConversationType>>; ) => Promise<Array<ConversationType>>;
makeSyncRequest: () => unknown; makeSyncRequest: () => unknown;
refreshCloudBackupStatus: () => void;
refreshBackupSubscriptionStatus: () => void;
removeCustomColor: (colorId: string) => unknown; removeCustomColor: (colorId: string) => unknown;
removeCustomColorOnConversations: (colorId: string) => unknown; removeCustomColorOnConversations: (colorId: string) => unknown;
resetAllChatColors: () => unknown; resetAllChatColors: () => unknown;
@ -210,7 +221,7 @@ export type PropsType = PropsDataType & PropsFunctionType;
export type PropsPreloadType = Omit<PropsType, 'i18n'>; export type PropsPreloadType = Omit<PropsType, 'i18n'>;
enum Page { export enum Page {
// Accessible through left nav // Accessible through left nav
General = 'General', General = 'General',
Appearance = 'Appearance', Appearance = 'Appearance',
@ -219,6 +230,7 @@ enum Page {
Notifications = 'Notifications', Notifications = 'Notifications',
Privacy = 'Privacy', Privacy = 'Privacy',
DataUsage = 'DataUsage', DataUsage = 'DataUsage',
Backups = 'Backups',
// Sub pages // Sub pages
ChatColor = 'ChatColor', ChatColor = 'ChatColor',
@ -260,8 +272,11 @@ export function Preferences({
availableLocales, availableLocales,
availableMicrophones, availableMicrophones,
availableSpeakers, availableSpeakers,
backupFeatureEnabled,
backupSubscriptionStatus,
blockedCount, blockedCount,
closeSettings, closeSettings,
cloudBackupStatus,
customColors, customColors,
defaultConversationColor, defaultConversationColor,
deviceName = '', deviceName = '',
@ -294,6 +309,7 @@ export function Preferences({
hasTextFormatting, hasTextFormatting,
hasTypingIndicators, hasTypingIndicators,
i18n, i18n,
initialPage = Page.General,
initialSpellCheckSetting, initialSpellCheckSetting,
isAutoDownloadUpdatesSupported, isAutoDownloadUpdatesSupported,
isAutoLaunchSupported, isAutoLaunchSupported,
@ -341,6 +357,8 @@ export function Preferences({
onZoomFactorChange, onZoomFactorChange,
phoneNumber = '', phoneNumber = '',
preferredSystemLocales, preferredSystemLocales,
refreshCloudBackupStatus,
refreshBackupSubscriptionStatus,
removeCustomColor, removeCustomColor,
removeCustomColorOnConversations, removeCustomColorOnConversations,
resetAllChatColors, resetAllChatColors,
@ -365,7 +383,7 @@ export function Preferences({
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmStoriesOff, setConfirmStoriesOff] = useState(false); const [confirmStoriesOff, setConfirmStoriesOff] = useState(false);
const [page, setPage] = useState<Page>(Page.General); const [page, setPage] = useState<Page>(initialPage);
const [showSyncFailed, setShowSyncFailed] = useState(false); const [showSyncFailed, setShowSyncFailed] = useState(false);
const [nowSyncing, setNowSyncing] = useState(false); const [nowSyncing, setNowSyncing] = useState(false);
const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] = const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] =
@ -385,6 +403,19 @@ export function Preferences({
setLanguageDialog(null); setLanguageDialog(null);
setSelectedLanguageLocale(localeOverride); setSelectedLanguageLocale(localeOverride);
} }
const shouldShowBackupsPage =
backupFeatureEnabled && backupSubscriptionStatus != null;
if (page === Page.Backups && !shouldShowBackupsPage) {
setPage(Page.General);
}
useEffect(() => {
if (page === Page.Backups) {
refreshCloudBackupStatus();
refreshBackupSubscriptionStatus();
}
}, [page, refreshCloudBackupStatus, refreshBackupSubscriptionStatus]);
useEffect(() => { useEffect(() => {
doneRendering(); doneRendering();
@ -1687,6 +1718,15 @@ export function Preferences({
)} )}
</> </>
); );
} else if (page === Page.Backups) {
settings = (
<PreferencesBackups
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={resolvedLocale}
/>
);
} }
return ( return (
@ -1775,6 +1815,19 @@ export function Preferences({
> >
{i18n('icu:Preferences__button--data-usage')} {i18n('icu:Preferences__button--data-usage')}
</button> </button>
{shouldShowBackupsPage ? (
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--backups': true,
'Preferences__button--selected': page === Page.Backups,
})}
onClick={() => setPage(Page.Backups)}
>
{i18n('icu:Preferences__button--backups')}
</button>
) : null}
</div> </div>
<div className="Preferences__settings-pane" ref={settingsPaneRef}> <div className="Preferences__settings-pane" ref={settingsPaneRef}>
{settings} {settings}
@ -1796,113 +1849,6 @@ export function Preferences({
); );
} }
function SettingsRow({
children,
title,
className,
}: {
children: ReactNode;
title?: string;
className?: string;
}): JSX.Element {
return (
<fieldset className={classNames('Preferences__settings-row', className)}>
{title && <legend className="Preferences__padding">{title}</legend>}
{children}
</fieldset>
);
}
function Control({
icon,
left,
onClick,
right,
}: {
/** A className or `true` to leave room for icon */
icon?: string | true;
left: ReactNode;
onClick?: () => unknown;
right: ReactNode;
}): JSX.Element {
const content = (
<>
{icon && (
<div
className={classNames(
'Preferences__control--icon',
icon === true ? null : icon
)}
/>
)}
<div className="Preferences__control--key">{left}</div>
<div className="Preferences__control--value">{right}</div>
</>
);
if (onClick) {
return (
<button
className="Preferences__control Preferences__control--clickable"
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return <div className="Preferences__control">{content}</div>;
}
type SettingsRadioOptionType<Enum> = Readonly<{
text: string;
value: Enum;
readOnly?: boolean;
onClick?: () => void;
}>;
function SettingsRadio<Enum>({
value,
options,
onChange,
}: {
value: Enum;
options: ReadonlyArray<SettingsRadioOptionType<Enum>>;
onChange: (value: Enum) => void;
}): JSX.Element {
const htmlIds = useMemo(() => {
return Array.from({ length: options.length }, () => uuid());
}, [options.length]);
return (
<div className="Preferences__padding">
{options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
const htmlId = htmlIds[i];
return (
<label
className={classNames('Preferences__settings-radio__label', {
'Preferences__settings-radio__label--readonly': readOnly,
})}
key={htmlId}
htmlFor={htmlId}
>
<CircleCheckbox
isRadio
variant={CircleCheckboxVariant.Small}
id={htmlId}
checked={value === optionValue}
onClick={onClick}
onChange={readOnly ? noop : () => onChange(optionValue)}
/>
{text}
</label>
);
})}
</div>
);
}
function localizeDefault(i18n: LocalizerType, deviceLabel: string): string { function localizeDefault(i18n: LocalizerType, deviceLabel: string): string {
return deviceLabel.toLowerCase().startsWith('default') return deviceLabel.toLowerCase().startsWith('default')
? deviceLabel.replace( ? deviceLabel.replace(

View file

@ -0,0 +1,220 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type {
BackupsSubscriptionType,
BackupStatusType,
} from '../types/backups';
import type { LocalizerType } from '../types/I18N';
import { formatTimestamp } from '../util/formatTimestamp';
import { formatFileSize } from '../util/formatFileSize';
import { SettingsRow } from './PreferencesUtil';
import { missingCaseError } from '../util/missingCaseError';
export function PreferencesBackups({
cloudBackupStatus,
backupSubscriptionStatus,
i18n,
locale,
}: {
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
locale: string;
}): JSX.Element {
return (
<>
<div className="Preferences__title Preferences__title--backups">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--backups')}
</div>
</div>
<div className="Preferences--backups-summary__container">
{getBackupsSubscriptionSummary({
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}
{cloudBackupStatus.mediaSize != null ||
cloudBackupStatus.protoSize != null}
<div className="Preferences--backup-details__row">
<label>
{i18n('icu:Preferences--backup-size__label')}{' '}
<div className="Preferences--backup-details__value">
{formatFileSize(
(cloudBackupStatus.mediaSize ?? 0) +
(cloudBackupStatus.protoSize ?? 0)
)}
</div>
</label>
</div>
</SettingsRow>
) : null}
</>
);
}
function getSubscriptionDetails({
i18n,
subscriptionStatus,
locale,
}: {
i18n: LocalizerType;
locale: string;
subscriptionStatus: BackupsSubscriptionType;
}): JSX.Element | null {
if (subscriptionStatus.status === 'active') {
return (
<>
{subscriptionStatus.cost ? (
<div className="Preferences--backups-summary__subscription-price">
{new Intl.NumberFormat(locale, {
style: 'currency',
currency: subscriptionStatus.cost.currencyCode,
currencyDisplay: 'narrowSymbol',
}).format(subscriptionStatus.cost.amount)}{' '}
/ month
</div>
) : null}
{subscriptionStatus.renewalDate ? (
<div className="Preferences--backups-summary__renewal-date">
{i18n('icu:Preferences--backup-plan__renewal-date', {
date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
if (subscriptionStatus.status === 'pending-cancellation') {
return (
<>
<div className="Preferences--backups-summary__canceled">
{i18n('icu:Preferences--backup-plan__canceled')}
</div>
{subscriptionStatus.expiryDate ? (
<div className="Preferences--backups-summary__expiry-date">
{i18n('icu:Preferences--backup-plan__expiry-date', {
date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
return null;
}
export function getBackupsSubscriptionSummary({
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>
{subscriptionStatus.status === 'active' ? (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--active" />
) : (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--inactive" />
)}
</div>
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-media-plan__note')}
</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 className="Preferences--backups-summary__icon Preferences--backups-summary__icon--active" />
</div>
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-messages-plan__note')}
</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 className="Preferences--backups-summary__icon Preferences--backups-summary__icon--inactive" />
</div>
<div className="Preferences--backups-summary__note">
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-plan__not-found__note')}
</div>
</div>
</>
);
default:
throw missingCaseError(status);
}
}

View file

@ -0,0 +1,118 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import classNames from 'classnames';
import React, { type ReactNode, useMemo } from 'react';
import { v4 as uuid } from 'uuid';
import { noop } from 'lodash';
import {
CircleCheckbox,
Variant as CircleCheckboxVariant,
} from './CircleCheckbox';
export function SettingsRow({
children,
title,
className,
}: {
children: ReactNode;
title?: string;
className?: string;
}): JSX.Element {
return (
<fieldset className={classNames('Preferences__settings-row', className)}>
{title && <legend className="Preferences__padding">{title}</legend>}
{children}
</fieldset>
);
}
export function SettingsControl({
icon,
left,
onClick,
right,
}: {
/** A className or `true` to leave room for icon */
icon?: string | true;
left: ReactNode;
onClick?: () => unknown;
right: ReactNode;
}): JSX.Element {
const content = (
<>
{icon && (
<div
className={classNames(
'Preferences__control--icon',
icon === true ? null : icon
)}
/>
)}
<div className="Preferences__control--key">{left}</div>
<div className="Preferences__control--value">{right}</div>
</>
);
if (onClick) {
return (
<button
className="Preferences__control Preferences__control--clickable"
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return <div className="Preferences__control">{content}</div>;
}
type SettingsRadioOptionType<Enum> = Readonly<{
text: string;
value: Enum;
readOnly?: boolean;
onClick?: () => void;
}>;
export function SettingsRadio<Enum>({
value,
options,
onChange,
}: {
value: Enum;
options: ReadonlyArray<SettingsRadioOptionType<Enum>>;
onChange: (value: Enum) => void;
}): JSX.Element {
const htmlIds = useMemo(() => {
return Array.from({ length: options.length }, () => uuid());
}, [options.length]);
return (
<div className="Preferences__padding">
{options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
const htmlId = htmlIds[i];
return (
<label
className={classNames('Preferences__settings-radio__label', {
'Preferences__settings-radio__label--readonly': readOnly,
})}
key={htmlId}
htmlFor={htmlId}
>
<CircleCheckbox
isRadio
variant={CircleCheckboxVariant.Small}
id={htmlId}
checked={value === optionValue}
onClick={onClick}
onChange={readOnly ? noop : () => onChange(optionValue)}
/>
{text}
</label>
);
})}
</div>
);
}

View file

@ -68,6 +68,13 @@ export class SettingsChannel extends EventEmitter {
this.#installCallback('setEmojiSkinToneDefault'); this.#installCallback('setEmojiSkinToneDefault');
this.#installCallback('getEmojiSkinToneDefault'); this.#installCallback('getEmojiSkinToneDefault');
// Backups
this.#installSetting('backupFeatureEnabled', { setter: false });
this.#installSetting('cloudBackupStatus', { setter: false });
this.#installSetting('backupSubscriptionStatus', { setter: false });
this.#installCallback('refreshCloudBackupStatus');
this.#installCallback('refreshBackupSubscriptionStatus');
// Getters only. These are set by the primary device // Getters only. These are set by the primary device
this.#installSetting('blockedCount', { setter: false }); this.#installSetting('blockedCount', { setter: false });
this.#installSetting('linkPreviewSetting', { setter: false }); this.#installSetting('linkPreviewSetting', { setter: false });

View file

@ -12,10 +12,18 @@ import type {
BackupMediaBatchResponseType, BackupMediaBatchResponseType,
BackupListMediaResponseType, BackupListMediaResponseType,
TransferArchiveType, TransferArchiveType,
SubscriptionResponseType,
} from '../../textsecure/WebAPI'; } from '../../textsecure/WebAPI';
import type { BackupCredentials } from './credentials'; import type { BackupCredentials } from './credentials';
import { BackupCredentialType } from '../../types/backups'; import {
BackupCredentialType,
type BackupsSubscriptionType,
type SubscriptionCostType,
} from '../../types/backups';
import { uploadFile } from '../../util/uploadAttachment'; import { uploadFile } from '../../util/uploadAttachment';
import { HTTPError } from '../../textsecure/Errors';
import * as log from '../../logging/log';
import { toLogFormat } from '../../types/errors';
export type DownloadOptionsType = Readonly<{ export type DownloadOptionsType = Readonly<{
downloadOffset: number; downloadOffset: number;
@ -113,6 +121,34 @@ export class BackupAPI {
}); });
} }
public async getBackupProtoInfo(): Promise<
| { backupExists: false }
| { backupExists: true; size: number; createdAt: Date }
> {
const { cdn, backupDir, backupName } = await this.#getCachedInfo(
BackupCredentialType.Messages
);
const { headers } = await this.credentials.getCDNReadCredentials(
cdn,
BackupCredentialType.Messages
);
try {
const { 'content-length': size, 'last-modified': createdAt } =
await this.#server.getBackupFileHeaders({
cdn,
backupDir,
backupName,
headers,
});
return { backupExists: true, size, createdAt };
} catch (error) {
if (error instanceof HTTPError && error.code === 404) {
return { backupExists: false };
}
throw error;
}
}
public async getTransferArchive( public async getTransferArchive(
abortSignal: AbortSignal abortSignal: AbortSignal
): Promise<TransferArchiveType> { ): Promise<TransferArchiveType> {
@ -169,6 +205,59 @@ export class BackupAPI {
}); });
} }
public async getSubscriptionInfo(): Promise<BackupsSubscriptionType> {
const subscriberId = window.storage.get('backupsSubscriberId');
if (!subscriberId) {
log.error('Backups.getSubscriptionInfo: missing subscriberId');
return { status: 'not-found' };
}
let subscriptionResponse: SubscriptionResponseType;
try {
subscriptionResponse = await this.#server.getSubscription(subscriberId);
} catch (e) {
log.error(
'Backups.getSubscriptionInfo: error fetching subscription',
toLogFormat(e)
);
return { status: 'not-found' };
}
const { subscription } = subscriptionResponse;
const { active, amount, currency, endOfCurrentPeriod, cancelAtPeriodEnd } =
subscription;
if (!active) {
return { status: 'expired' };
}
let cost: SubscriptionCostType | undefined;
if (amount && currency) {
cost = {
amount,
currencyCode: currency,
};
} else {
log.error(
'Backups.getSubscriptionInfo: invalid amount/currency returned for active subscription'
);
}
if (cancelAtPeriodEnd) {
return {
status: 'pending-cancellation',
cost,
expiryDate: endOfCurrentPeriod,
};
}
return {
status: 'active',
cost,
renewalDate: endOfCurrentPeriod,
};
}
public clearCache(): void { public clearCache(): void {
this.#cachedBackupInfo.clear(); this.#cachedBackupInfo.clear();
} }

View file

@ -13,6 +13,7 @@ import { createCipheriv, createHmac, randomBytes } from 'crypto';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup';
import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys'; import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys';
import { throttle } from 'lodash/fp';
import { DataReader, DataWriter } from '../../sql/Client'; import { DataReader, DataWriter } from '../../sql/Client';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
@ -26,7 +27,7 @@ import { appendMacStream } from '../../util/appendMacStream';
import { getIvAndDecipher } from '../../util/getIvAndDecipher'; import { getIvAndDecipher } from '../../util/getIvAndDecipher';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac'; import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { HOUR } from '../../util/durations'; import { DAY, HOUR, MINUTE } from '../../util/durations';
import type { ExplodePromiseResultType } from '../../util/explodePromise'; import type { ExplodePromiseResultType } from '../../util/explodePromise';
import { explodePromise } from '../../util/explodePromise'; import { explodePromise } from '../../util/explodePromise';
import type { RetryBackupImportValue } from '../../state/ducks/installer'; import type { RetryBackupImportValue } from '../../state/ducks/installer';
@ -36,7 +37,11 @@ import {
InstallScreenBackupError, InstallScreenBackupError,
} from '../../types/InstallScreen'; } from '../../types/InstallScreen';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
import { BackupCredentialType } from '../../types/backups'; import {
BackupCredentialType,
type BackupsSubscriptionType,
type BackupStatusType,
} from '../../types/backups';
import { HTTPError } from '../../textsecure/Errors'; import { HTTPError } from '../../textsecure/Errors';
import { constantTimeEqual } from '../../Crypto'; import { constantTimeEqual } from '../../Crypto';
import { measureSize } from '../../AttachmentCrypto'; import { measureSize } from '../../AttachmentCrypto';
@ -58,6 +63,7 @@ import {
} from './errors'; } from './errors';
import { ToastType } from '../../types/Toast'; import { ToastType } from '../../types/Toast';
import { isAdhoc, isNightly } from '../../util/version'; import { isAdhoc, isNightly } from '../../util/version';
import { getMessageQueueTime } from '../../util/getMessageQueueTime';
export { BackupType }; export { BackupType };
@ -102,6 +108,14 @@ export class BackupsService {
public readonly credentials = new BackupCredentials(); public readonly credentials = new BackupCredentials();
public readonly api = new BackupAPI(this.credentials); public readonly api = new BackupAPI(this.credentials);
public readonly throttledFetchCloudBackupStatus = throttle(
MINUTE,
this.fetchCloudBackupStatus.bind(this)
);
public readonly throttledFetchSubscriptionStatus = throttle(
MINUTE,
this.fetchSubscriptionStatus.bind(this)
);
public start(): void { public start(): void {
if (this.#isStarted) { if (this.#isStarted) {
@ -778,6 +792,8 @@ export class BackupsService {
} catch (error) { } catch (error) {
log.error('Backup: periodic refresh failed', Errors.toLogFormat(error)); log.error('Backup: periodic refresh failed', Errors.toLogFormat(error));
} }
drop(this.fetchCloudBackupStatus());
drop(this.fetchSubscriptionStatus());
} }
async #unlinkAndDeleteAllData() { async #unlinkAndDeleteAllData() {
@ -812,6 +828,75 @@ export class BackupsService {
public isExportRunning(): boolean { public isExportRunning(): boolean {
return this.#isRunning === 'export'; return this.#isRunning === 'export';
} }
#getBackupTierFromStorage(): BackupLevel | null {
const backupTier = window.storage.get('backupTier');
switch (backupTier) {
case BackupLevel.Free:
return BackupLevel.Free;
case BackupLevel.Paid:
return BackupLevel.Paid;
case undefined:
return null;
default:
log.error('Unknown backupTier in storage', backupTier);
return null;
}
}
async #getBackedUpMediaSize(): Promise<number> {
const backupInfo = await this.api.getInfo(BackupCredentialType.Media);
return backupInfo.usedSpace ?? 0;
}
async fetchCloudBackupStatus(): Promise<BackupStatusType | undefined> {
let result: BackupStatusType | undefined;
const [backupProtoInfo, mediaSize] = await Promise.all([
this.api.getBackupProtoInfo(),
this.#getBackedUpMediaSize(),
]);
if (backupProtoInfo.backupExists) {
const { createdAt, size: protoSize } = backupProtoInfo;
result = {
createdAt: createdAt.getTime(),
protoSize,
mediaSize,
};
}
await window.storage.put('cloudBackupStatus', result);
return result;
}
async fetchSubscriptionStatus(): Promise<
BackupsSubscriptionType | undefined
> {
const backupTier = this.#getBackupTierFromStorage();
let result: BackupsSubscriptionType;
switch (backupTier) {
case null:
case undefined:
case BackupLevel.Free:
result = {
status: 'free',
mediaIncludedInBackupDurationDays: getMessageQueueTime() / DAY,
};
break;
case BackupLevel.Paid:
result = await this.api.getSubscriptionInfo();
break;
default:
throw missingCaseError(backupTier);
}
drop(window.storage.put('backupSubscriptionStatus', result));
return result;
}
getCachedCloudBackupStatus(): BackupStatusType | undefined {
return window.storage.get('cloudBackupStatus');
}
} }
export const backupsService = new BackupsService(); export const backupsService = new BackupsService();

View file

@ -469,6 +469,10 @@ export function toAccountRecord(
} }
accountRecord.backupSubscriberData = generateBackupsSubscriberData(); accountRecord.backupSubscriberData = generateBackupsSubscriberData();
const backupTier = window.storage.get('backupTier');
if (backupTier) {
accountRecord.backupTier = Long.fromNumber(backupTier);
}
const displayBadgesOnProfile = window.storage.get('displayBadgesOnProfile'); const displayBadgesOnProfile = window.storage.get('displayBadgesOnProfile');
if (displayBadgesOnProfile !== undefined) { if (displayBadgesOnProfile !== undefined) {
@ -1398,6 +1402,7 @@ export async function mergeAccountRecord(
subscriberCurrencyCode, subscriberCurrencyCode,
donorSubscriptionManuallyCancelled, donorSubscriptionManuallyCancelled,
backupSubscriberData, backupSubscriberData,
backupTier,
displayBadgesOnProfile, displayBadgesOnProfile,
keepMutedChatsArchived, keepMutedChatsArchived,
hasCompletedUsernameOnboarding, hasCompletedUsernameOnboarding,
@ -1614,6 +1619,7 @@ export async function mergeAccountRecord(
} }
await saveBackupsSubscriberData(backupSubscriberData); await saveBackupsSubscriberData(backupSubscriberData);
await window.storage.put('backupTier', backupTier?.toNumber());
await window.storage.put( await window.storage.put(
'displayBadgesOnProfile', 'displayBadgesOnProfile',

View file

@ -17,7 +17,6 @@ import { type Loadable, LoadingState } from '../../util/loadable';
import { isRecord } from '../../util/isRecord'; import { isRecord } from '../../util/isRecord';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import * as Registration from '../../util/registration'; import * as Registration from '../../util/registration';
import { isBackupEnabled } from '../../util/isBackupEnabled';
import { missingCaseError } from '../../util/missingCaseError'; import { missingCaseError } from '../../util/missingCaseError';
import { HTTPError } from '../../textsecure/Errors'; import { HTTPError } from '../../textsecure/Errors';
import { import {
@ -322,7 +321,7 @@ function finishInstall({
const accountManager = window.getAccountManager(); const accountManager = window.getAccountManager();
strictAssert(accountManager, 'Expected an account manager'); strictAssert(accountManager, 'Expected an account manager');
if (isBackupEnabled() || isLinkAndSync) { if (isLinkAndSync) {
dispatch({ type: SHOW_BACKUP_IMPORT }); dispatch({ type: SHOW_BACKUP_IMPORT });
} else { } else {
dispatch({ type: SHOW_LINK_IN_PROGRESS }); dispatch({ type: SHOW_LINK_IN_PROGRESS });

View file

@ -63,7 +63,7 @@ import { SignalService as Proto } from '../protobuf';
import * as log from '../logging/log'; import * as log from '../logging/log';
import type { StorageAccessType } from '../types/Storage'; import type { StorageAccessType } from '../types/Storage';
import { getRelativePath, createName } from '../util/attachmentPath'; import { getRelativePath, createName } from '../util/attachmentPath';
import { isBackupEnabled } from '../util/isBackupEnabled'; import { isBackupFeatureEnabled } from '../util/isBackupEnabled';
import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled'; import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled';
import { getMessageQueueTime } from '../util/getMessageQueueTime'; import { getMessageQueueTime } from '../util/getMessageQueueTime';
@ -1115,7 +1115,7 @@ export default class AccountManager extends EventTarget {
} }
const shouldDownloadBackup = const shouldDownloadBackup =
isBackupEnabled() || isBackupFeatureEnabled() ||
(isLinkAndSyncEnabled() && options.ephemeralBackupKey); (isLinkAndSyncEnabled() && options.ephemeralBackupKey);
// Set backup download path before storing credentials to ensure that // Set backup download path before storing credentials to ensure that

View file

@ -18,7 +18,6 @@ import type { Readable } from 'stream';
import { Net } from '@signalapp/libsignal-client'; import { Net } from '@signalapp/libsignal-client';
import { assertDev, strictAssert } from '../util/assert'; import { assertDev, strictAssert } from '../util/assert';
import { drop } from '../util/drop'; import { drop } from '../util/drop';
import { isRecord } from '../util/isRecord';
import * as durations from '../util/durations'; import * as durations from '../util/durations';
import type { ExplodePromiseResultType } from '../util/explodePromise'; import type { ExplodePromiseResultType } from '../util/explodePromise';
import { explodePromise } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise';
@ -212,6 +211,7 @@ type PromiseAjaxOptionsType = {
| 'jsonwithdetails' | 'jsonwithdetails'
| 'bytes' | 'bytes'
| 'byteswithdetails' | 'byteswithdetails'
| 'raw'
| 'stream' | 'stream'
| 'streamwithdetails'; | 'streamwithdetails';
stack?: string; stack?: string;
@ -251,6 +251,33 @@ type StreamWithDetailsType = {
response: Response; response: Response;
}; };
type GetAttachmentArgsType = {
cdnPath: string;
cdnNumber: number;
headers?: Record<string, string>;
redactor: RedactUrl;
options?: {
disableRetries?: boolean;
timeout?: number;
downloadOffset?: number;
onProgress?: (currentBytes: number, totalBytes: number) => void;
abortSignal?: AbortSignal;
};
};
type GetAttachmentFromBackupTierArgsType = {
mediaId: string;
backupDir: string;
mediaDir: string;
cdnNumber: number;
headers: Record<string, string>;
options?: {
disableRetries?: boolean;
timeout?: number;
downloadOffset?: number;
};
};
export const multiRecipient200ResponseSchema = z.object({ export const multiRecipient200ResponseSchema = z.object({
uuids404: z.array(serviceIdSchema).optional(), uuids404: z.array(serviceIdSchema).optional(),
needsSync: z.boolean().optional(), needsSync: z.boolean().optional(),
@ -466,6 +493,8 @@ async function _promiseAjax(
options.responseType === 'streamwithdetails' options.responseType === 'streamwithdetails'
) { ) {
result = response.body; result = response.body;
} else if (options.responseType === 'raw') {
result = response;
} else { } else {
result = await response.textConverted(); result = await response.textConverted();
} }
@ -619,6 +648,10 @@ function _outerAjax(
providedUrl: string | null, providedUrl: string | null,
options: PromiseAjaxOptionsType & { responseType: 'streamwithdetails' } options: PromiseAjaxOptionsType & { responseType: 'streamwithdetails' }
): Promise<StreamWithDetailsType>; ): Promise<StreamWithDetailsType>;
function _outerAjax(
providedUrl: string | null,
options: PromiseAjaxOptionsType & { responseType: 'raw' }
): Promise<Response>;
function _outerAjax( function _outerAjax(
providedUrl: string | null, providedUrl: string | null,
options: PromiseAjaxOptionsType options: PromiseAjaxOptionsType
@ -1354,6 +1387,29 @@ export type ProxiedRequestParams = Readonly<{
signal?: AbortSignal; signal?: AbortSignal;
}>; }>;
const backupFileHeadersSchema = z.object({
'content-length': z.coerce.number(),
'last-modified': z.coerce.date(),
});
type BackupFileHeadersType = z.infer<typeof backupFileHeadersSchema>;
const subscriptionResponseSchema = z.object({
subscription: z.object({
level: z.number(),
billingCycleAnchor: z.coerce.date().optional(),
endOfCurrentPeriod: z.coerce.date().optional(),
active: z.boolean(),
cancelAtPeriodEnd: z.boolean().optional(),
currency: z.string().optional(),
amount: z.number().nonnegative().optional(),
}),
});
export type SubscriptionResponseType = z.infer<
typeof subscriptionResponseSchema
>;
export type WebAPIType = { export type WebAPIType = {
startRegistration(): unknown; startRegistration(): unknown;
finishRegistration(baton: unknown): void; finishRegistration(baton: unknown): void;
@ -1371,19 +1427,9 @@ export type WebAPIType = {
version: string, version: string,
imageFiles: Array<string> imageFiles: Array<string>
) => Promise<Array<Uint8Array>>; ) => Promise<Array<Uint8Array>>;
getAttachmentFromBackupTier: (args: { getAttachmentFromBackupTier: (
mediaId: string; args: GetAttachmentFromBackupTierArgsType
backupDir: string; ) => Promise<Readable>;
mediaDir: string;
cdnNumber: number;
headers: Record<string, string>;
options?: {
disableRetries?: boolean;
timeout?: number;
downloadOffset?: number;
abortSignal: AbortSignal;
};
}) => Promise<Readable>;
getAttachment: (args: { getAttachment: (args: {
cdnKey: string; cdnKey: string;
cdnNumber?: number; cdnNumber?: number;
@ -1444,6 +1490,9 @@ export type WebAPIType = {
getSubscriptionConfiguration: ( getSubscriptionConfiguration: (
userLanguages: ReadonlyArray<string> userLanguages: ReadonlyArray<string>
) => Promise<unknown>; ) => Promise<unknown>;
getSubscription: (
subscriberId: Uint8Array
) => Promise<SubscriptionResponseType>;
getProvisioningResource: ( getProvisioningResource: (
handler: IRequestHandler, handler: IRequestHandler,
timeout?: number timeout?: number
@ -1572,6 +1621,12 @@ export type WebAPIType = {
headers: BackupPresentationHeadersType headers: BackupPresentationHeadersType
) => Promise<GetBackupInfoResponseType>; ) => Promise<GetBackupInfoResponseType>;
getBackupStream: (options: GetBackupStreamOptionsType) => Promise<Readable>; getBackupStream: (options: GetBackupStreamOptionsType) => Promise<Readable>;
getBackupFileHeaders: (
options: Pick<
GetBackupStreamOptionsType,
'cdn' | 'backupDir' | 'backupName' | 'headers'
>
) => Promise<{ 'content-length': number; 'last-modified': Date }>;
getEphemeralBackupStream: ( getEphemeralBackupStream: (
options: GetEphemeralBackupStreamOptionsType options: GetEphemeralBackupStreamOptionsType
) => Promise<Readable>; ) => Promise<Readable>;
@ -1950,6 +2005,7 @@ export function initialize({
getBackupCDNCredentials, getBackupCDNCredentials,
getBackupInfo, getBackupInfo,
getBackupStream, getBackupStream,
getBackupFileHeaders,
getBackupMediaUploadForm, getBackupMediaUploadForm,
getBackupUploadForm, getBackupUploadForm,
getBadgeImageFile, getBadgeImageFile,
@ -1985,6 +2041,7 @@ export function initialize({
getStorageCredentials, getStorageCredentials,
getStorageManifest, getStorageManifest,
getStorageRecords, getStorageRecords,
getSubscription,
getSubscriptionConfiguration, getSubscriptionConfiguration,
linkDevice, linkDevice,
logout, logout,
@ -3244,6 +3301,25 @@ export function initialize({
}, },
}); });
} }
async function getBackupFileHeaders({
headers,
cdn,
backupDir,
backupName,
}: Pick<
GetBackupStreamOptionsType,
'headers' | 'cdn' | 'backupDir' | 'backupName'
>): Promise<BackupFileHeadersType> {
const result = await _getAttachmentHeaders({
cdnPath: `/backups/${encodeURIComponent(backupDir)}/${encodeURIComponent(backupName)}`,
cdnNumber: cdn,
redactor: _createRedactor(backupDir, backupName),
headers,
});
const responseHeaders = Object.fromEntries(result.entries());
return parseUnknown(backupFileHeadersSchema, responseHeaders as unknown);
}
async function getEphemeralBackupStream({ async function getEphemeralBackupStream({
cdn, cdn,
@ -3974,20 +4050,14 @@ export function initialize({
cdnNumber, cdnNumber,
headers, headers,
options, options,
}: { }: GetAttachmentFromBackupTierArgsType) {
mediaId: string;
backupDir: string;
mediaDir: string;
cdnNumber: number;
headers: Record<string, string>;
options?: {
disableRetries?: boolean;
timeout?: number;
downloadOffset?: number;
};
}) {
return _getAttachment({ return _getAttachment({
cdnPath: `/backups/${backupDir}/${mediaDir}/${mediaId}`, cdnPath: urlPathFromComponents([
'backups',
backupDir,
mediaDir,
mediaId,
]),
cdnNumber, cdnNumber,
headers, headers,
redactor: _createRedactor(backupDir, mediaDir, mediaId), redactor: _createRedactor(backupDir, mediaDir, mediaId),
@ -3995,27 +4065,44 @@ export function initialize({
}); });
} }
function getCheckedCdnUrl(cdnNumber: number, cdnPath: string) {
const baseUrl = cdnUrlObject[cdnNumber] ?? cdnUrlObject['0'];
const { origin: expectedOrigin } = new URL(baseUrl);
const fullCdnUrl = `${baseUrl}${cdnPath}`;
const { origin } = new URL(fullCdnUrl);
strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`);
return fullCdnUrl;
}
async function _getAttachmentHeaders({
cdnPath,
cdnNumber,
headers = {},
redactor,
}: Omit<GetAttachmentArgsType, 'options'>): Promise<fetch.Headers> {
const fullCdnUrl = getCheckedCdnUrl(cdnNumber, cdnPath);
const response = await _outerAjax(fullCdnUrl, {
headers,
certificateAuthority,
proxyUrl,
responseType: 'raw',
timeout: DEFAULT_TIMEOUT,
type: 'HEAD',
redactUrl: redactor,
version,
});
return response.headers;
}
async function _getAttachment({ async function _getAttachment({
cdnPath, cdnPath,
cdnNumber, cdnNumber,
headers = {}, headers = {},
redactor, redactor,
options, options,
}: { }: GetAttachmentArgsType): Promise<Readable> {
cdnPath: string;
cdnNumber: number;
headers?: Record<string, string>;
redactor: RedactUrl;
options?: {
disableRetries?: boolean;
timeout?: number;
downloadOffset?: number;
onProgress?: (currentBytes: number, totalBytes: number) => void;
abortSignal?: AbortSignal;
};
}): Promise<Readable> {
const abortController = new AbortController(); const abortController = new AbortController();
const cdnUrl = cdnUrlObject[cdnNumber] ?? cdnUrlObject['0'];
let streamWithDetails: StreamWithDetailsType | undefined; let streamWithDetails: StreamWithDetailsType | undefined;
@ -4035,10 +4122,7 @@ export function initialize({
if (options?.downloadOffset) { if (options?.downloadOffset) {
targetHeaders.range = `bytes=${options.downloadOffset}-`; targetHeaders.range = `bytes=${options.downloadOffset}-`;
} }
const { origin: expectedOrigin } = new URL(cdnUrl); const fullCdnUrl = getCheckedCdnUrl(cdnNumber, cdnPath);
const fullCdnUrl = `${cdnUrl}${cdnPath}`;
const { origin } = new URL(fullCdnUrl);
strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`);
streamWithDetails = await _outerAjax(fullCdnUrl, { streamWithDetails = await _outerAjax(fullCdnUrl, {
headers: targetHeaders, headers: targetHeaders,
@ -4075,7 +4159,7 @@ export function initialize({
); );
strictAssert( strictAssert(
!streamWithDetails.contentType?.includes('multipart'), !streamWithDetails.contentType?.includes('multipart'),
`Expected non-multipart response for ${cdnUrl}${cdnPath}` 'Expected non-multipart response'
); );
const range = streamWithDetails.response.headers.get('content-range'); const range = streamWithDetails.response.headers.get('content-range');
@ -4686,11 +4770,11 @@ export function initialize({
}; };
} }
async function getHasSubscription( async function getSubscription(
subscriberId: Uint8Array subscriberId: Uint8Array
): Promise<boolean> { ): Promise<SubscriptionResponseType> {
const formattedId = toWebSafeBase64(Bytes.toBase64(subscriberId)); const formattedId = toWebSafeBase64(Bytes.toBase64(subscriberId));
const data = await _ajax({ const response = await _ajax({
call: 'subscriptions', call: 'subscriptions',
httpType: 'GET', httpType: 'GET',
urlParameters: `/${formattedId}`, urlParameters: `/${formattedId}`,
@ -4701,11 +4785,14 @@ export function initialize({
redactUrl: _createRedactor(formattedId), redactUrl: _createRedactor(formattedId),
}); });
return ( return parseUnknown(subscriptionResponseSchema, response);
isRecord(data) && }
isRecord(data.subscription) &&
Boolean(data.subscription.active) async function getHasSubscription(
); subscriberId: Uint8Array
): Promise<boolean> {
const data = await getSubscription(subscriberId);
return data.subscription.active;
} }
function getProvisioningResource( function getProvisioningResource(

10
ts/types/Storage.d.ts vendored
View file

@ -17,7 +17,11 @@ import type {
SessionResetsType, SessionResetsType,
StorageServiceCredentials, StorageServiceCredentials,
} from '../textsecure/Types.d'; } from '../textsecure/Types.d';
import type { BackupCredentialWrapperType } from './backups'; import type {
BackupCredentialWrapperType,
BackupsSubscriptionType,
BackupStatusType,
} from './backups';
import type { ServiceIdString } from './ServiceId'; import type { ServiceIdString } from './ServiceId';
import type { RegisteredChallengeType } from '../challenge'; import type { RegisteredChallengeType } from '../challenge';
@ -222,6 +226,10 @@ export type StorageAccessType = {
key: string; key: string;
}; };
backupTier: number | undefined;
cloudBackupStatus: BackupStatusType | undefined;
backupSubscriptionStatus: BackupsSubscriptionType;
// If true Desktop message history was restored from backup // If true Desktop message history was restored from backup
isRestoredFromBackup: boolean; isRestoredFromBackup: boolean;

View file

@ -29,3 +29,35 @@ export type BackupCdnReadCredentialType = Readonly<{
retrievedAtMs: number; retrievedAtMs: number;
cdnNumber: number; cdnNumber: number;
}>; }>;
export type SubscriptionCostType = {
amount: number;
currencyCode: string;
};
export type BackupStatusType = {
createdAt?: number;
protoSize?: number;
mediaSize?: number;
};
export type BackupsSubscriptionType =
| {
status: 'not-found' | 'expired';
}
| {
status: 'free';
mediaIncludedInBackupDurationDays: number;
}
| (
| {
status: 'active';
renewalDate?: Date;
cost?: SubscriptionCostType;
}
| {
status: 'pending-cancellation';
expiryDate?: Date;
cost?: SubscriptionCostType;
}
);

View file

@ -60,6 +60,11 @@ import { sendSyncRequests } from '../textsecure/syncRequests';
import { waitForEvent } from '../shims/events'; import { waitForEvent } from '../shims/events';
import { DEFAULT_AUTO_DOWNLOAD_ATTACHMENT } from '../textsecure/Storage'; import { DEFAULT_AUTO_DOWNLOAD_ATTACHMENT } from '../textsecure/Storage';
import { EmojiSkinTone } from '../components/fun/data/emojis'; import { EmojiSkinTone } from '../components/fun/data/emojis';
import type {
BackupsSubscriptionType,
BackupStatusType,
} from '../types/backups';
import { isBackupFeatureEnabled } from './isBackupEnabled';
type SentMediaQualityType = 'standard' | 'high'; type SentMediaQualityType = 'standard' | 'high';
type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off';
@ -95,7 +100,9 @@ export type IPCEventsValuesType = {
mediaCameraPermissions: boolean | undefined; mediaCameraPermissions: boolean | undefined;
// Only getters // Only getters
backupFeatureEnabled: boolean;
cloudBackupStatus: BackupStatusType | undefined;
backupSubscriptionStatus: BackupsSubscriptionType | undefined;
blockedCount: number; blockedCount: number;
linkPreviewSetting: boolean; linkPreviewSetting: boolean;
phoneNumberDiscoverabilitySetting: PhoneNumberDiscoverability; phoneNumberDiscoverabilitySetting: PhoneNumberDiscoverability;
@ -114,6 +121,8 @@ export type IPCEventsCallbacksType = {
availableMicrophones: Array<AudioDevice>; availableMicrophones: Array<AudioDevice>;
availableSpeakers: Array<AudioDevice>; availableSpeakers: Array<AudioDevice>;
}>; }>;
refreshCloudBackupStatus(): void;
refreshBackupSubscriptionStatus(): void;
addCustomColor: (customColor: CustomColorType) => void; addCustomColor: (customColor: CustomColorType) => void;
addDarkOverlay: () => void; addDarkOverlay: () => void;
cleanupDownloads: () => Promise<void>; cleanupDownloads: () => Promise<void>;
@ -185,6 +194,9 @@ type ValuesWithSetters = Omit<
| 'typingIndicatorSetting' | 'typingIndicatorSetting'
| 'deviceName' | 'deviceName'
| 'phoneNumber' | 'phoneNumber'
| 'backupFeatureEnabled'
| 'cloudBackupStatus'
| 'backupSubscriptionStatus'
// Optional // Optional
| 'mediaPermissions' | 'mediaPermissions'
@ -394,6 +406,19 @@ export function createIPCEvents(
availableSpeakers, availableSpeakers,
}; };
}, },
getBackupFeatureEnabled: () => {
return isBackupFeatureEnabled();
},
getCloudBackupStatus: () => {
return window.storage.get('cloudBackupStatus');
},
getBackupSubscriptionStatus: () => {
return window.storage.get('backupSubscriptionStatus');
},
refreshCloudBackupStatus:
window.Signal.Services.backups.throttledFetchCloudBackupStatus,
refreshBackupSubscriptionStatus:
window.Signal.Services.backups.throttledFetchSubscriptionStatus,
getBlockedCount: () => getBlockedCount: () =>
window.storage.blocked.getBlockedServiceIds().length + window.storage.blocked.getBlockedServiceIds().length +
window.storage.blocked.getBlockedGroups().length, window.storage.blocked.getBlockedGroups().length,

View file

@ -5,7 +5,7 @@ import * as RemoteConfig from '../RemoteConfig';
import { isTestOrMockEnvironment } from '../environment'; import { isTestOrMockEnvironment } from '../environment';
import { isStagingServer } from './isStagingServer'; import { isStagingServer } from './isStagingServer';
export function isBackupEnabled(): boolean { export function isBackupFeatureEnabled(): boolean {
if (isStagingServer() || isTestOrMockEnvironment()) { if (isStagingServer() || isTestOrMockEnvironment()) {
return true; return true;
} }

View file

@ -19,6 +19,16 @@ installCallback('resetDefaultChatColor');
installCallback('setGlobalDefaultConversationColor'); installCallback('setGlobalDefaultConversationColor');
installCallback('getDefaultConversationColor'); installCallback('getDefaultConversationColor');
installSetting('backupFeatureEnabled', {
setter: false,
});
installSetting('backupSubscriptionStatus', {
setter: false,
});
installSetting('cloudBackupStatus', {
setter: false,
});
// Getters only. These are set by the primary device // Getters only. These are set by the primary device
installSetting('blockedCount', { installSetting('blockedCount', {
setter: false, setter: false,
@ -33,6 +43,8 @@ installSetting('typingIndicatorSetting', {
setter: false, setter: false,
}); });
installCallback('refreshCloudBackupStatus');
installCallback('refreshBackupSubscriptionStatus');
installCallback('deleteAllMyStories'); installCallback('deleteAllMyStories');
installCallback('isPrimary'); installCallback('isPrimary');
installCallback('syncRequest'); installCallback('syncRequest');

View file

@ -30,8 +30,11 @@ SettingsWindowProps.onRender(
availableLocales, availableLocales,
availableMicrophones, availableMicrophones,
availableSpeakers, availableSpeakers,
backupFeatureEnabled,
backupSubscriptionStatus,
blockedCount, blockedCount,
closeSettings, closeSettings,
cloudBackupStatus,
customColors, customColors,
defaultConversationColor, defaultConversationColor,
deviceName, deviceName,
@ -110,6 +113,8 @@ SettingsWindowProps.onRender(
onWhoCanSeeMeChange, onWhoCanSeeMeChange,
onZoomFactorChange, onZoomFactorChange,
preferredSystemLocales, preferredSystemLocales,
refreshCloudBackupStatus,
refreshBackupSubscriptionStatus,
removeCustomColor, removeCustomColor,
removeCustomColorOnConversations, removeCustomColorOnConversations,
resetAllChatColors, resetAllChatColors,
@ -135,8 +140,11 @@ SettingsWindowProps.onRender(
availableLocales={availableLocales} availableLocales={availableLocales}
availableMicrophones={availableMicrophones} availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers} availableSpeakers={availableSpeakers}
backupFeatureEnabled={backupFeatureEnabled}
backupSubscriptionStatus={backupSubscriptionStatus}
blockedCount={blockedCount} blockedCount={blockedCount}
closeSettings={closeSettings} closeSettings={closeSettings}
cloudBackupStatus={cloudBackupStatus}
customColors={customColors} customColors={customColors}
defaultConversationColor={defaultConversationColor} defaultConversationColor={defaultConversationColor}
deviceName={deviceName} deviceName={deviceName}
@ -221,6 +229,8 @@ SettingsWindowProps.onRender(
onWhoCanSeeMeChange={onWhoCanSeeMeChange} onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange} onZoomFactorChange={onZoomFactorChange}
preferredSystemLocales={preferredSystemLocales} preferredSystemLocales={preferredSystemLocales}
refreshCloudBackupStatus={refreshCloudBackupStatus}
refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus}
removeCustomColorOnConversations={removeCustomColorOnConversations} removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor} removeCustomColor={removeCustomColor}
resetAllChatColors={resetAllChatColors} resetAllChatColors={resetAllChatColors}

View file

@ -60,6 +60,18 @@ const settingZoomFactor = createSetting('zoomFactor');
// Getters only. // Getters only.
const settingBlockedCount = createSetting('blockedCount'); const settingBlockedCount = createSetting('blockedCount');
const settingBackupFeatureEnabled = createSetting('backupFeatureEnabled', {
setter: false,
});
const settingCloudBackupStatus = createSetting('cloudBackupStatus', {
setter: false,
});
const settingBackupSubscriptionStatus = createSetting(
'backupSubscriptionStatus',
{
setter: false,
}
);
const settingLinkPreview = createSetting('linkPreviewSetting', { const settingLinkPreview = createSetting('linkPreviewSetting', {
setter: false, setter: false,
}); });
@ -88,6 +100,10 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault');
const ipcIsSyncNotSupported = createCallback('isPrimary'); const ipcIsSyncNotSupported = createCallback('isPrimary');
const ipcMakeSyncRequest = createCallback('syncRequest'); const ipcMakeSyncRequest = createCallback('syncRequest');
const ipcDeleteAllMyStories = createCallback('deleteAllMyStories'); const ipcDeleteAllMyStories = createCallback('deleteAllMyStories');
const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus');
const ipcRefreshBackupSubscriptionStatus = createCallback(
'refreshBackupSubscriptionStatus'
);
// ChatColorPicker redux hookups // ChatColorPicker redux hookups
// The redux actions update over IPC through a preferences re-render // The redux actions update over IPC through a preferences re-render
@ -144,7 +160,10 @@ function attachRenderCallback<Value>(f: (value: Value) => Promise<Value>) {
async function renderPreferences() { async function renderPreferences() {
const { const {
autoDownloadAttachment, autoDownloadAttachment,
backupFeatureEnabled,
backupSubscriptionStatus,
blockedCount, blockedCount,
cloudBackupStatus,
deviceName, deviceName,
emojiSkinToneDefault, emojiSkinToneDefault,
hasAudioNotifications, hasAudioNotifications,
@ -188,7 +207,10 @@ async function renderPreferences() {
isSyncNotSupported, isSyncNotSupported,
} = await awaitObject({ } = await awaitObject({
autoDownloadAttachment: settingAutoDownloadAttachment.getValue(), autoDownloadAttachment: settingAutoDownloadAttachment.getValue(),
backupFeatureEnabled: settingBackupFeatureEnabled.getValue(),
backupSubscriptionStatus: settingBackupSubscriptionStatus.getValue(),
blockedCount: settingBlockedCount.getValue(), blockedCount: settingBlockedCount.getValue(),
cloudBackupStatus: settingCloudBackupStatus.getValue(),
deviceName: settingDeviceName.getValue(), deviceName: settingDeviceName.getValue(),
hasAudioNotifications: settingAudioNotification.getValue(), hasAudioNotifications: settingAudioNotification.getValue(),
hasAutoConvertEmoji: settingAutoConvertEmoji.getValue(), hasAutoConvertEmoji: settingAutoConvertEmoji.getValue(),
@ -279,7 +301,10 @@ async function renderPreferences() {
availableLocales, availableLocales,
availableMicrophones, availableMicrophones,
availableSpeakers, availableSpeakers,
backupFeatureEnabled,
backupSubscriptionStatus,
blockedCount, blockedCount,
cloudBackupStatus,
customColors, customColors,
defaultConversationColor, defaultConversationColor,
deviceName, deviceName,
@ -333,12 +358,13 @@ async function renderPreferences() {
initialSpellCheckSetting: initialSpellCheckSetting:
MinimalSignalContext.config.appStartInitialSpellcheckSetting, MinimalSignalContext.config.appStartInitialSpellcheckSetting,
makeSyncRequest: ipcMakeSyncRequest, makeSyncRequest: ipcMakeSyncRequest,
refreshCloudBackupStatus: ipcRefreshCloudBackupStatus,
refreshBackupSubscriptionStatus: ipcRefreshBackupSubscriptionStatus,
removeCustomColor: ipcRemoveCustomColor, removeCustomColor: ipcRemoveCustomColor,
removeCustomColorOnConversations: ipcRemoveCustomColorOnConversations, removeCustomColorOnConversations: ipcRemoveCustomColorOnConversations,
resetAllChatColors: ipcResetAllChatColors, resetAllChatColors: ipcResetAllChatColors,
resetDefaultChatColor: ipcResetDefaultChatColor, resetDefaultChatColor: ipcResetDefaultChatColor,
setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor, setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor,
// Limited support features // Limited support features
isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported( isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported(
OS, OS,