From aba0e028d4e7c69a8770eb944d44c11f3497fd32 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Wed, 2 Apr 2025 14:57:29 -0400 Subject: [PATCH] Show backup status in Settings window --- _locales/en/messages.json | 64 +++++ images/icons/v3/backup/backups-logo-dark.svg | 9 + images/icons/v3/backup/backups-logo-light.svg | 9 + .../v3/backup/backups-subscribed-dark.svg | 9 + .../v3/backup/backups-subscribed-light.svg | 9 + stylesheets/components/Preferences.scss | 109 ++++++++- ts/background.ts | 4 +- ts/components/Preferences.stories.tsx | 84 ++++++- ts/components/Preferences.tsx | 176 +++++--------- ts/components/PreferencesBackups.tsx | 220 ++++++++++++++++++ ts/components/PreferencesUtil.tsx | 118 ++++++++++ ts/main/settingsChannel.ts | 7 + ts/services/backups/api.ts | 91 +++++++- ts/services/backups/index.ts | 89 ++++++- ts/services/storageRecordOps.ts | 6 + ts/state/ducks/installer.ts | 3 +- ts/textsecure/AccountManager.ts | 4 +- ts/textsecure/WebAPI.ts | 195 +++++++++++----- ts/types/Storage.d.ts | 10 +- ts/types/backups.ts | 32 +++ ts/util/createIPCEvents.ts | 27 ++- ts/util/isBackupEnabled.ts | 2 +- ts/windows/preload.ts | 12 + ts/windows/settings/app.tsx | 10 + ts/windows/settings/preload.ts | 28 ++- 25 files changed, 1136 insertions(+), 191 deletions(-) create mode 100644 images/icons/v3/backup/backups-logo-dark.svg create mode 100644 images/icons/v3/backup/backups-logo-light.svg create mode 100644 images/icons/v3/backup/backups-subscribed-dark.svg create mode 100644 images/icons/v3/backup/backups-subscribed-light.svg create mode 100644 ts/components/PreferencesBackups.tsx create mode 100644 ts/components/PreferencesUtil.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 15c59983c35..ebff5611ea7 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6587,6 +6587,10 @@ "messageformat": "Voice messages and stickers are always auto-downloaded.", "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": { "messageformat": "Last import at {date} {time}", "description": "Label for date and time of last sync operation" @@ -6627,6 +6631,66 @@ "messageformat": "Blocked", "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": { "messageformat": "{num, plural, one {# contact} other {# contacts}}", "description": "Number of contacts blocked plural" diff --git a/images/icons/v3/backup/backups-logo-dark.svg b/images/icons/v3/backup/backups-logo-dark.svg new file mode 100644 index 00000000000..0c559be9479 --- /dev/null +++ b/images/icons/v3/backup/backups-logo-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/images/icons/v3/backup/backups-logo-light.svg b/images/icons/v3/backup/backups-logo-light.svg new file mode 100644 index 00000000000..04143242029 --- /dev/null +++ b/images/icons/v3/backup/backups-logo-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/images/icons/v3/backup/backups-subscribed-dark.svg b/images/icons/v3/backup/backups-subscribed-dark.svg new file mode 100644 index 00000000000..2abe23e390e --- /dev/null +++ b/images/icons/v3/backup/backups-subscribed-dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/images/icons/v3/backup/backups-subscribed-light.svg b/images/icons/v3/backup/backups-subscribed-light.svg new file mode 100644 index 00000000000..1782a6acefa --- /dev/null +++ b/images/icons/v3/backup/backups-subscribed-light.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index 13af0167f88..519da7aa5f0 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -15,6 +15,11 @@ } } +$secondary-text-color: light-dark( + variables.$color-gray-60, + variables.$color-gray-25 +); + .Preferences { display: flex; overflow: hidden; @@ -111,6 +116,10 @@ &--data-usage { @include preferences-icon('../images/icons/v3/data/data.svg'); } + + &--backups { + @include preferences-icon('../images/icons/v3/backup/backup-bold.svg'); + } } &__settings-pane { @@ -140,6 +149,11 @@ border-color: variables.$color-gray-65; } + &--backups { + border: none; + margin-bottom: 16px; + } + &--header { flex-grow: 1; text-align: center; @@ -247,13 +261,7 @@ &__description { @include mixins.font-subtitle; - @include mixins.light-theme { - color: variables.$color-gray-60; - } - @include mixins.dark-theme { - color: variables.$color-gray-25; - } - + color: $secondary-text-color; &--error { color: variables.$color-accent-red !important; } @@ -444,3 +452,90 @@ 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; + } + } +} diff --git a/ts/background.ts b/ts/background.ts index 5631e1ab3cd..9a0b0282fe3 100644 --- a/ts/background.ts +++ b/ts/background.ts @@ -40,7 +40,7 @@ import { isWindowDragElement } from './util/isWindowDragElement'; import { assertDev, strictAssert } from './util/assert'; import { filter } from './util/iterables'; import { isNotNil } from './util/isNotNil'; -import { isBackupEnabled } from './util/isBackupEnabled'; +import { isBackupFeatureEnabled } from './util/isBackupEnabled'; import { setAppLoadingScreenMessage } from './setAppLoadingScreenMessage'; import { IdleDetector } from './IdleDetector'; import { @@ -1949,7 +1949,7 @@ export async function startApp(): Promise { drop(window.Signal.Services.initializeGroupCredentialFetcher()); drop(AttachmentDownloadManager.start()); - if (isBackupEnabled()) { + if (isBackupFeatureEnabled()) { backupsService.start(); drop(AttachmentBackupManager.start()); } diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 33decefc70a..9985a639234 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -6,12 +6,12 @@ import React from 'react'; import { action } from '@storybook/addon-actions'; import type { PropsType } from './Preferences'; -import { Preferences } from './Preferences'; +import { Page, Preferences } from './Preferences'; import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors'; import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; -import { DurationInSeconds } from '../util/durations'; import { EmojiSkinTone } from './fun/data/emojis'; +import { DAY, DurationInSeconds, WEEK } from '../util/durations'; const { i18n } = window.SignalContext; @@ -76,6 +76,7 @@ export default { availableLocales: ['en'], availableMicrophones, availableSpeakers, + backupFeatureEnabled: false, blockedCount: 0, customColors: {}, defaultConversationColor: DEFAULT_CONVERSATION_COLOR, @@ -177,6 +178,8 @@ export default { onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'), onWhoCanFindMeChange: action('onWhoCanFindMeChange'), onZoomFactorChange: action('onZoomFactorChange'), + refreshCloudBackupStatus: action('refreshCloudBackupStatus'), + refreshBackupSubscriptionStatus: action('refreshBackupSubscriptionStatus'), removeCustomColor: action('removeCustomColor'), removeCustomColorOnConversations: action( 'removeCustomColorOnConversations' @@ -220,3 +223,80 @@ PNPDiscoverabilityDisabled.args = { whoCanSeeMe: PhoneNumberSharingMode.Nobody, 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', + }, +}; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 613b13eed90..82fee0d508e 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -2,7 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { AudioDevice } from '@signalapp/ringrtc'; -import type { ReactNode } from 'react'; import React, { useCallback, useEffect, @@ -12,7 +11,6 @@ import React, { } from 'react'; import { noop, partition } from 'lodash'; import classNames from 'classnames'; -import { v4 as uuid } from 'uuid'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import type { MediaDeviceSettings } from '../types/Calling'; @@ -41,10 +39,7 @@ import { Button, ButtonVariant } from './Button'; import { ChatColorPicker } from './ChatColorPicker'; import { Checkbox } from './Checkbox'; import { WidthBreakpoint } from './_util'; -import { - CircleCheckbox, - Variant as CircleCheckboxVariant, -} from './CircleCheckbox'; + import { ConfirmationDialog } from './ConfirmationDialog'; import { DisappearingTimeDialog } from './DisappearingTimeDialog'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; @@ -70,6 +65,16 @@ import { assertDev } from '../util/assert'; import { I18n } from './I18n'; import { FunSkinTonesList } from './fun/FunSkinTones'; 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 SelectChangeHandlerType = (value: T) => unknown; @@ -77,7 +82,10 @@ type SelectChangeHandlerType = (value: T) => unknown; export type PropsDataType = { // Settings autoDownloadAttachment: AutoDownloadAttachmentType; + backupFeatureEnabled: boolean; blockedCount: number; + cloudBackupStatus?: BackupStatusType; + backupSubscriptionStatus?: BackupsSubscriptionType; customColors: Record; defaultConversationColor: DefaultConversationColorType; deviceName?: string; @@ -105,6 +113,7 @@ export type PropsDataType = { hasStoriesDisabled: boolean; hasTextFormatting: boolean; hasTypingIndicators: boolean; + initialPage?: Page; lastSyncTime?: number; notificationContent: NotificationSettingType; phoneNumber: string | undefined; @@ -152,6 +161,8 @@ type PropsFunctionType = { colorId: string ) => Promise>; makeSyncRequest: () => unknown; + refreshCloudBackupStatus: () => void; + refreshBackupSubscriptionStatus: () => void; removeCustomColor: (colorId: string) => unknown; removeCustomColorOnConversations: (colorId: string) => unknown; resetAllChatColors: () => unknown; @@ -210,7 +221,7 @@ export type PropsType = PropsDataType & PropsFunctionType; export type PropsPreloadType = Omit; -enum Page { +export enum Page { // Accessible through left nav General = 'General', Appearance = 'Appearance', @@ -219,6 +230,7 @@ enum Page { Notifications = 'Notifications', Privacy = 'Privacy', DataUsage = 'DataUsage', + Backups = 'Backups', // Sub pages ChatColor = 'ChatColor', @@ -260,8 +272,11 @@ export function Preferences({ availableLocales, availableMicrophones, availableSpeakers, + backupFeatureEnabled, + backupSubscriptionStatus, blockedCount, closeSettings, + cloudBackupStatus, customColors, defaultConversationColor, deviceName = '', @@ -294,6 +309,7 @@ export function Preferences({ hasTextFormatting, hasTypingIndicators, i18n, + initialPage = Page.General, initialSpellCheckSetting, isAutoDownloadUpdatesSupported, isAutoLaunchSupported, @@ -341,6 +357,8 @@ export function Preferences({ onZoomFactorChange, phoneNumber = '', preferredSystemLocales, + refreshCloudBackupStatus, + refreshBackupSubscriptionStatus, removeCustomColor, removeCustomColorOnConversations, resetAllChatColors, @@ -365,7 +383,7 @@ export function Preferences({ const [confirmDelete, setConfirmDelete] = useState(false); const [confirmStoriesOff, setConfirmStoriesOff] = useState(false); - const [page, setPage] = useState(Page.General); + const [page, setPage] = useState(initialPage); const [showSyncFailed, setShowSyncFailed] = useState(false); const [nowSyncing, setNowSyncing] = useState(false); const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] = @@ -385,6 +403,19 @@ export function Preferences({ setLanguageDialog(null); 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(() => { doneRendering(); @@ -1687,6 +1718,15 @@ export function Preferences({ )} ); + } else if (page === Page.Backups) { + settings = ( + + ); } return ( @@ -1775,6 +1815,19 @@ export function Preferences({ > {i18n('icu:Preferences__button--data-usage')} + {shouldShowBackupsPage ? ( + + ) : null}
{settings} @@ -1796,113 +1849,6 @@ export function Preferences({ ); } -function SettingsRow({ - children, - title, - className, -}: { - children: ReactNode; - title?: string; - className?: string; -}): JSX.Element { - return ( -
- {title && {title}} - {children} -
- ); -} - -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 && ( -
- )} -
{left}
-
{right}
- - ); - - if (onClick) { - return ( - - ); - } - - return
{content}
; -} - -type SettingsRadioOptionType = Readonly<{ - text: string; - value: Enum; - readOnly?: boolean; - onClick?: () => void; -}>; - -function SettingsRadio({ - value, - options, - onChange, -}: { - value: Enum; - options: ReadonlyArray>; - onChange: (value: Enum) => void; -}): JSX.Element { - const htmlIds = useMemo(() => { - return Array.from({ length: options.length }, () => uuid()); - }, [options.length]); - - return ( -
- {options.map(({ text, value: optionValue, readOnly, onClick }, i) => { - const htmlId = htmlIds[i]; - return ( - - ); - })} -
- ); -} - function localizeDefault(i18n: LocalizerType, deviceLabel: string): string { return deviceLabel.toLowerCase().startsWith('default') ? deviceLabel.replace( diff --git a/ts/components/PreferencesBackups.tsx b/ts/components/PreferencesBackups.tsx new file mode 100644 index 00000000000..2896833d554 --- /dev/null +++ b/ts/components/PreferencesBackups.tsx @@ -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 ( + <> +
+
+ {i18n('icu:Preferences__button--backups')} +
+
+ +
+ {getBackupsSubscriptionSummary({ + subscriptionStatus: backupSubscriptionStatus, + i18n, + locale, + })} +
+ + {cloudBackupStatus ? ( + + {cloudBackupStatus.createdAt ? ( +
+ +
+ {/* TODO (DESKTOP-8509) */} + {i18n('icu:Preferences--backup-created-by-phone')} + + {formatTimestamp(cloudBackupStatus.createdAt, { + dateStyle: 'medium', + timeStyle: 'short', + })} +
+
+ ) : null} + {cloudBackupStatus.mediaSize != null || + cloudBackupStatus.protoSize != null} +
+ +
+
+ ) : null} + + ); +} +function getSubscriptionDetails({ + i18n, + subscriptionStatus, + locale, +}: { + i18n: LocalizerType; + locale: string; + subscriptionStatus: BackupsSubscriptionType; +}): JSX.Element | null { + if (subscriptionStatus.status === 'active') { + return ( + <> + {subscriptionStatus.cost ? ( +
+ {new Intl.NumberFormat(locale, { + style: 'currency', + currency: subscriptionStatus.cost.currencyCode, + currencyDisplay: 'narrowSymbol', + }).format(subscriptionStatus.cost.amount)}{' '} + / month +
+ ) : null} + {subscriptionStatus.renewalDate ? ( +
+ {i18n('icu:Preferences--backup-plan__renewal-date', { + date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), { + dateStyle: 'medium', + }), + })} +
+ ) : null} + + ); + } + if (subscriptionStatus.status === 'pending-cancellation') { + return ( + <> +
+ {i18n('icu:Preferences--backup-plan__canceled')} +
+ {subscriptionStatus.expiryDate ? ( +
+ {i18n('icu:Preferences--backup-plan__expiry-date', { + date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), { + dateStyle: 'medium', + }), + })} +
+ ) : 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 ( + <> +
+
+
+ {i18n('icu:Preferences--backup-media-plan__description')} +
+
+ {getSubscriptionDetails({ i18n, locale, subscriptionStatus })} +
+
+ {subscriptionStatus.status === 'active' ? ( +
+ ) : ( +
+ )} +
+
+ {i18n('icu:Preferences--backup-media-plan__note')} +
+ + ); + case 'free': + return ( + <> +
+
+
+ {i18n('icu:Preferences--backup-messages-plan__description', { + mediaDayCount: + subscriptionStatus.mediaIncludedInBackupDurationDays, + })} +
+
+ {i18n( + 'icu:Preferences--backup-messages-plan__cost-description' + )} +
+
+
+
+
+ {i18n('icu:Preferences--backup-messages-plan__note')} +
+ + ); + case 'not-found': + case 'expired': + return ( + <> +
+
+ {i18n('icu:Preferences--backup-plan-not-found__description')} +
+
+
+
+
+ {i18n('icu:Preferences--backup-plan__not-found__note')} +
+
+ + ); + default: + throw missingCaseError(status); + } +} diff --git a/ts/components/PreferencesUtil.tsx b/ts/components/PreferencesUtil.tsx new file mode 100644 index 00000000000..a885dc8d28f --- /dev/null +++ b/ts/components/PreferencesUtil.tsx @@ -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 ( +
+ {title && {title}} + {children} +
+ ); +} + +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 && ( +
+ )} +
{left}
+
{right}
+ + ); + + if (onClick) { + return ( + + ); + } + + return
{content}
; +} + +type SettingsRadioOptionType = Readonly<{ + text: string; + value: Enum; + readOnly?: boolean; + onClick?: () => void; +}>; + +export function SettingsRadio({ + value, + options, + onChange, +}: { + value: Enum; + options: ReadonlyArray>; + onChange: (value: Enum) => void; +}): JSX.Element { + const htmlIds = useMemo(() => { + return Array.from({ length: options.length }, () => uuid()); + }, [options.length]); + + return ( +
+ {options.map(({ text, value: optionValue, readOnly, onClick }, i) => { + const htmlId = htmlIds[i]; + return ( + + ); + })} +
+ ); +} diff --git a/ts/main/settingsChannel.ts b/ts/main/settingsChannel.ts index dbb5583c504..674282ae7c1 100644 --- a/ts/main/settingsChannel.ts +++ b/ts/main/settingsChannel.ts @@ -68,6 +68,13 @@ export class SettingsChannel extends EventEmitter { this.#installCallback('setEmojiSkinToneDefault'); 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 this.#installSetting('blockedCount', { setter: false }); this.#installSetting('linkPreviewSetting', { setter: false }); diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index caa1da5a6e0..4e7d18c6e94 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -12,10 +12,18 @@ import type { BackupMediaBatchResponseType, BackupListMediaResponseType, TransferArchiveType, + SubscriptionResponseType, } from '../../textsecure/WebAPI'; 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 { HTTPError } from '../../textsecure/Errors'; +import * as log from '../../logging/log'; +import { toLogFormat } from '../../types/errors'; export type DownloadOptionsType = Readonly<{ 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( abortSignal: AbortSignal ): Promise { @@ -169,6 +205,59 @@ export class BackupAPI { }); } + public async getSubscriptionInfo(): Promise { + 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 { this.#cachedBackupInfo.clear(); } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 219b6f29b63..2b9c04c6599 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -13,6 +13,7 @@ import { createCipheriv, createHmac, randomBytes } from 'crypto'; import { noop } from 'lodash'; import { BackupLevel } from '@signalapp/libsignal-client/zkgroup'; import { BackupKey } from '@signalapp/libsignal-client/dist/AccountKeys'; +import { throttle } from 'lodash/fp'; import { DataReader, DataWriter } from '../../sql/Client'; import * as log from '../../logging/log'; @@ -26,7 +27,7 @@ import { appendMacStream } from '../../util/appendMacStream'; import { getIvAndDecipher } from '../../util/getIvAndDecipher'; import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac'; import { missingCaseError } from '../../util/missingCaseError'; -import { HOUR } from '../../util/durations'; +import { DAY, HOUR, MINUTE } from '../../util/durations'; import type { ExplodePromiseResultType } from '../../util/explodePromise'; import { explodePromise } from '../../util/explodePromise'; import type { RetryBackupImportValue } from '../../state/ducks/installer'; @@ -36,7 +37,11 @@ import { InstallScreenBackupError, } from '../../types/InstallScreen'; 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 { constantTimeEqual } from '../../Crypto'; import { measureSize } from '../../AttachmentCrypto'; @@ -58,6 +63,7 @@ import { } from './errors'; import { ToastType } from '../../types/Toast'; import { isAdhoc, isNightly } from '../../util/version'; +import { getMessageQueueTime } from '../../util/getMessageQueueTime'; export { BackupType }; @@ -102,6 +108,14 @@ export class BackupsService { public readonly credentials = new BackupCredentials(); 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 { if (this.#isStarted) { @@ -778,6 +792,8 @@ export class BackupsService { } catch (error) { log.error('Backup: periodic refresh failed', Errors.toLogFormat(error)); } + drop(this.fetchCloudBackupStatus()); + drop(this.fetchSubscriptionStatus()); } async #unlinkAndDeleteAllData() { @@ -812,6 +828,75 @@ export class BackupsService { public isExportRunning(): boolean { 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 { + const backupInfo = await this.api.getInfo(BackupCredentialType.Media); + return backupInfo.usedSpace ?? 0; + } + + async fetchCloudBackupStatus(): Promise { + 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(); diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index fed0645a1bf..da521662c65 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -469,6 +469,10 @@ export function toAccountRecord( } accountRecord.backupSubscriberData = generateBackupsSubscriberData(); + const backupTier = window.storage.get('backupTier'); + if (backupTier) { + accountRecord.backupTier = Long.fromNumber(backupTier); + } const displayBadgesOnProfile = window.storage.get('displayBadgesOnProfile'); if (displayBadgesOnProfile !== undefined) { @@ -1398,6 +1402,7 @@ export async function mergeAccountRecord( subscriberCurrencyCode, donorSubscriptionManuallyCancelled, backupSubscriberData, + backupTier, displayBadgesOnProfile, keepMutedChatsArchived, hasCompletedUsernameOnboarding, @@ -1614,6 +1619,7 @@ export async function mergeAccountRecord( } await saveBackupsSubscriberData(backupSubscriberData); + await window.storage.put('backupTier', backupTier?.toNumber()); await window.storage.put( 'displayBadgesOnProfile', diff --git a/ts/state/ducks/installer.ts b/ts/state/ducks/installer.ts index 01903e59e74..268f8de2edd 100644 --- a/ts/state/ducks/installer.ts +++ b/ts/state/ducks/installer.ts @@ -17,7 +17,6 @@ import { type Loadable, LoadingState } from '../../util/loadable'; import { isRecord } from '../../util/isRecord'; import { strictAssert } from '../../util/assert'; import * as Registration from '../../util/registration'; -import { isBackupEnabled } from '../../util/isBackupEnabled'; import { missingCaseError } from '../../util/missingCaseError'; import { HTTPError } from '../../textsecure/Errors'; import { @@ -322,7 +321,7 @@ function finishInstall({ const accountManager = window.getAccountManager(); strictAssert(accountManager, 'Expected an account manager'); - if (isBackupEnabled() || isLinkAndSync) { + if (isLinkAndSync) { dispatch({ type: SHOW_BACKUP_IMPORT }); } else { dispatch({ type: SHOW_LINK_IN_PROGRESS }); diff --git a/ts/textsecure/AccountManager.ts b/ts/textsecure/AccountManager.ts index f0c325fe5b7..6a74f708ba7 100644 --- a/ts/textsecure/AccountManager.ts +++ b/ts/textsecure/AccountManager.ts @@ -63,7 +63,7 @@ import { SignalService as Proto } from '../protobuf'; import * as log from '../logging/log'; import type { StorageAccessType } from '../types/Storage'; import { getRelativePath, createName } from '../util/attachmentPath'; -import { isBackupEnabled } from '../util/isBackupEnabled'; +import { isBackupFeatureEnabled } from '../util/isBackupEnabled'; import { isLinkAndSyncEnabled } from '../util/isLinkAndSyncEnabled'; import { getMessageQueueTime } from '../util/getMessageQueueTime'; @@ -1115,7 +1115,7 @@ export default class AccountManager extends EventTarget { } const shouldDownloadBackup = - isBackupEnabled() || + isBackupFeatureEnabled() || (isLinkAndSyncEnabled() && options.ephemeralBackupKey); // Set backup download path before storing credentials to ensure that diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 235d6eac082..7c9e667215d 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -18,7 +18,6 @@ import type { Readable } from 'stream'; import { Net } from '@signalapp/libsignal-client'; import { assertDev, strictAssert } from '../util/assert'; import { drop } from '../util/drop'; -import { isRecord } from '../util/isRecord'; import * as durations from '../util/durations'; import type { ExplodePromiseResultType } from '../util/explodePromise'; import { explodePromise } from '../util/explodePromise'; @@ -212,6 +211,7 @@ type PromiseAjaxOptionsType = { | 'jsonwithdetails' | 'bytes' | 'byteswithdetails' + | 'raw' | 'stream' | 'streamwithdetails'; stack?: string; @@ -251,6 +251,33 @@ type StreamWithDetailsType = { response: Response; }; +type GetAttachmentArgsType = { + cdnPath: string; + cdnNumber: number; + headers?: Record; + 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; + options?: { + disableRetries?: boolean; + timeout?: number; + downloadOffset?: number; + }; +}; + export const multiRecipient200ResponseSchema = z.object({ uuids404: z.array(serviceIdSchema).optional(), needsSync: z.boolean().optional(), @@ -466,6 +493,8 @@ async function _promiseAjax( options.responseType === 'streamwithdetails' ) { result = response.body; + } else if (options.responseType === 'raw') { + result = response; } else { result = await response.textConverted(); } @@ -619,6 +648,10 @@ function _outerAjax( providedUrl: string | null, options: PromiseAjaxOptionsType & { responseType: 'streamwithdetails' } ): Promise; +function _outerAjax( + providedUrl: string | null, + options: PromiseAjaxOptionsType & { responseType: 'raw' } +): Promise; function _outerAjax( providedUrl: string | null, options: PromiseAjaxOptionsType @@ -1354,6 +1387,29 @@ export type ProxiedRequestParams = Readonly<{ signal?: AbortSignal; }>; +const backupFileHeadersSchema = z.object({ + 'content-length': z.coerce.number(), + 'last-modified': z.coerce.date(), +}); + +type BackupFileHeadersType = z.infer; + +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 = { startRegistration(): unknown; finishRegistration(baton: unknown): void; @@ -1371,19 +1427,9 @@ export type WebAPIType = { version: string, imageFiles: Array ) => Promise>; - getAttachmentFromBackupTier: (args: { - mediaId: string; - backupDir: string; - mediaDir: string; - cdnNumber: number; - headers: Record; - options?: { - disableRetries?: boolean; - timeout?: number; - downloadOffset?: number; - abortSignal: AbortSignal; - }; - }) => Promise; + getAttachmentFromBackupTier: ( + args: GetAttachmentFromBackupTierArgsType + ) => Promise; getAttachment: (args: { cdnKey: string; cdnNumber?: number; @@ -1444,6 +1490,9 @@ export type WebAPIType = { getSubscriptionConfiguration: ( userLanguages: ReadonlyArray ) => Promise; + getSubscription: ( + subscriberId: Uint8Array + ) => Promise; getProvisioningResource: ( handler: IRequestHandler, timeout?: number @@ -1572,6 +1621,12 @@ export type WebAPIType = { headers: BackupPresentationHeadersType ) => Promise; getBackupStream: (options: GetBackupStreamOptionsType) => Promise; + getBackupFileHeaders: ( + options: Pick< + GetBackupStreamOptionsType, + 'cdn' | 'backupDir' | 'backupName' | 'headers' + > + ) => Promise<{ 'content-length': number; 'last-modified': Date }>; getEphemeralBackupStream: ( options: GetEphemeralBackupStreamOptionsType ) => Promise; @@ -1950,6 +2005,7 @@ export function initialize({ getBackupCDNCredentials, getBackupInfo, getBackupStream, + getBackupFileHeaders, getBackupMediaUploadForm, getBackupUploadForm, getBadgeImageFile, @@ -1985,6 +2041,7 @@ export function initialize({ getStorageCredentials, getStorageManifest, getStorageRecords, + getSubscription, getSubscriptionConfiguration, linkDevice, logout, @@ -3244,6 +3301,25 @@ export function initialize({ }, }); } + async function getBackupFileHeaders({ + headers, + cdn, + backupDir, + backupName, + }: Pick< + GetBackupStreamOptionsType, + 'headers' | 'cdn' | 'backupDir' | 'backupName' + >): Promise { + 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({ cdn, @@ -3974,20 +4050,14 @@ export function initialize({ cdnNumber, headers, options, - }: { - mediaId: string; - backupDir: string; - mediaDir: string; - cdnNumber: number; - headers: Record; - options?: { - disableRetries?: boolean; - timeout?: number; - downloadOffset?: number; - }; - }) { + }: GetAttachmentFromBackupTierArgsType) { return _getAttachment({ - cdnPath: `/backups/${backupDir}/${mediaDir}/${mediaId}`, + cdnPath: urlPathFromComponents([ + 'backups', + backupDir, + mediaDir, + mediaId, + ]), cdnNumber, headers, 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): Promise { + 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({ cdnPath, cdnNumber, headers = {}, redactor, options, - }: { - cdnPath: string; - cdnNumber: number; - headers?: Record; - redactor: RedactUrl; - options?: { - disableRetries?: boolean; - timeout?: number; - downloadOffset?: number; - onProgress?: (currentBytes: number, totalBytes: number) => void; - abortSignal?: AbortSignal; - }; - }): Promise { + }: GetAttachmentArgsType): Promise { const abortController = new AbortController(); - const cdnUrl = cdnUrlObject[cdnNumber] ?? cdnUrlObject['0']; let streamWithDetails: StreamWithDetailsType | undefined; @@ -4035,10 +4122,7 @@ export function initialize({ if (options?.downloadOffset) { targetHeaders.range = `bytes=${options.downloadOffset}-`; } - const { origin: expectedOrigin } = new URL(cdnUrl); - const fullCdnUrl = `${cdnUrl}${cdnPath}`; - const { origin } = new URL(fullCdnUrl); - strictAssert(origin === expectedOrigin, `Unexpected origin: ${origin}`); + const fullCdnUrl = getCheckedCdnUrl(cdnNumber, cdnPath); streamWithDetails = await _outerAjax(fullCdnUrl, { headers: targetHeaders, @@ -4075,7 +4159,7 @@ export function initialize({ ); strictAssert( !streamWithDetails.contentType?.includes('multipart'), - `Expected non-multipart response for ${cdnUrl}${cdnPath}` + 'Expected non-multipart response' ); const range = streamWithDetails.response.headers.get('content-range'); @@ -4686,11 +4770,11 @@ export function initialize({ }; } - async function getHasSubscription( + async function getSubscription( subscriberId: Uint8Array - ): Promise { + ): Promise { const formattedId = toWebSafeBase64(Bytes.toBase64(subscriberId)); - const data = await _ajax({ + const response = await _ajax({ call: 'subscriptions', httpType: 'GET', urlParameters: `/${formattedId}`, @@ -4701,11 +4785,14 @@ export function initialize({ redactUrl: _createRedactor(formattedId), }); - return ( - isRecord(data) && - isRecord(data.subscription) && - Boolean(data.subscription.active) - ); + return parseUnknown(subscriptionResponseSchema, response); + } + + async function getHasSubscription( + subscriberId: Uint8Array + ): Promise { + const data = await getSubscription(subscriberId); + return data.subscription.active; } function getProvisioningResource( diff --git a/ts/types/Storage.d.ts b/ts/types/Storage.d.ts index bf70e8928ec..f060fa43e0c 100644 --- a/ts/types/Storage.d.ts +++ b/ts/types/Storage.d.ts @@ -17,7 +17,11 @@ import type { SessionResetsType, StorageServiceCredentials, } from '../textsecure/Types.d'; -import type { BackupCredentialWrapperType } from './backups'; +import type { + BackupCredentialWrapperType, + BackupsSubscriptionType, + BackupStatusType, +} from './backups'; import type { ServiceIdString } from './ServiceId'; import type { RegisteredChallengeType } from '../challenge'; @@ -222,6 +226,10 @@ export type StorageAccessType = { key: string; }; + backupTier: number | undefined; + cloudBackupStatus: BackupStatusType | undefined; + backupSubscriptionStatus: BackupsSubscriptionType; + // If true Desktop message history was restored from backup isRestoredFromBackup: boolean; diff --git a/ts/types/backups.ts b/ts/types/backups.ts index 2a23dbd5a20..290c73cd5ef 100644 --- a/ts/types/backups.ts +++ b/ts/types/backups.ts @@ -29,3 +29,35 @@ export type BackupCdnReadCredentialType = Readonly<{ retrievedAtMs: 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; + } + ); diff --git a/ts/util/createIPCEvents.ts b/ts/util/createIPCEvents.ts index e41daae6371..8932b4d41c6 100644 --- a/ts/util/createIPCEvents.ts +++ b/ts/util/createIPCEvents.ts @@ -60,6 +60,11 @@ import { sendSyncRequests } from '../textsecure/syncRequests'; import { waitForEvent } from '../shims/events'; import { DEFAULT_AUTO_DOWNLOAD_ATTACHMENT } from '../textsecure/Storage'; import { EmojiSkinTone } from '../components/fun/data/emojis'; +import type { + BackupsSubscriptionType, + BackupStatusType, +} from '../types/backups'; +import { isBackupFeatureEnabled } from './isBackupEnabled'; type SentMediaQualityType = 'standard' | 'high'; type NotificationSettingType = 'message' | 'name' | 'count' | 'off'; @@ -95,7 +100,9 @@ export type IPCEventsValuesType = { mediaCameraPermissions: boolean | undefined; // Only getters - + backupFeatureEnabled: boolean; + cloudBackupStatus: BackupStatusType | undefined; + backupSubscriptionStatus: BackupsSubscriptionType | undefined; blockedCount: number; linkPreviewSetting: boolean; phoneNumberDiscoverabilitySetting: PhoneNumberDiscoverability; @@ -114,6 +121,8 @@ export type IPCEventsCallbacksType = { availableMicrophones: Array; availableSpeakers: Array; }>; + refreshCloudBackupStatus(): void; + refreshBackupSubscriptionStatus(): void; addCustomColor: (customColor: CustomColorType) => void; addDarkOverlay: () => void; cleanupDownloads: () => Promise; @@ -185,6 +194,9 @@ type ValuesWithSetters = Omit< | 'typingIndicatorSetting' | 'deviceName' | 'phoneNumber' + | 'backupFeatureEnabled' + | 'cloudBackupStatus' + | 'backupSubscriptionStatus' // Optional | 'mediaPermissions' @@ -394,6 +406,19 @@ export function createIPCEvents( 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: () => window.storage.blocked.getBlockedServiceIds().length + window.storage.blocked.getBlockedGroups().length, diff --git a/ts/util/isBackupEnabled.ts b/ts/util/isBackupEnabled.ts index 92704dd280a..9c4dd6346be 100644 --- a/ts/util/isBackupEnabled.ts +++ b/ts/util/isBackupEnabled.ts @@ -5,7 +5,7 @@ import * as RemoteConfig from '../RemoteConfig'; import { isTestOrMockEnvironment } from '../environment'; import { isStagingServer } from './isStagingServer'; -export function isBackupEnabled(): boolean { +export function isBackupFeatureEnabled(): boolean { if (isStagingServer() || isTestOrMockEnvironment()) { return true; } diff --git a/ts/windows/preload.ts b/ts/windows/preload.ts index c039064c252..415c136c251 100644 --- a/ts/windows/preload.ts +++ b/ts/windows/preload.ts @@ -19,6 +19,16 @@ installCallback('resetDefaultChatColor'); installCallback('setGlobalDefaultConversationColor'); installCallback('getDefaultConversationColor'); +installSetting('backupFeatureEnabled', { + setter: false, +}); +installSetting('backupSubscriptionStatus', { + setter: false, +}); +installSetting('cloudBackupStatus', { + setter: false, +}); + // Getters only. These are set by the primary device installSetting('blockedCount', { setter: false, @@ -33,6 +43,8 @@ installSetting('typingIndicatorSetting', { setter: false, }); +installCallback('refreshCloudBackupStatus'); +installCallback('refreshBackupSubscriptionStatus'); installCallback('deleteAllMyStories'); installCallback('isPrimary'); installCallback('syncRequest'); diff --git a/ts/windows/settings/app.tsx b/ts/windows/settings/app.tsx index 5694ed53615..8ecc53f35b4 100644 --- a/ts/windows/settings/app.tsx +++ b/ts/windows/settings/app.tsx @@ -30,8 +30,11 @@ SettingsWindowProps.onRender( availableLocales, availableMicrophones, availableSpeakers, + backupFeatureEnabled, + backupSubscriptionStatus, blockedCount, closeSettings, + cloudBackupStatus, customColors, defaultConversationColor, deviceName, @@ -110,6 +113,8 @@ SettingsWindowProps.onRender( onWhoCanSeeMeChange, onZoomFactorChange, preferredSystemLocales, + refreshCloudBackupStatus, + refreshBackupSubscriptionStatus, removeCustomColor, removeCustomColorOnConversations, resetAllChatColors, @@ -135,8 +140,11 @@ SettingsWindowProps.onRender( availableLocales={availableLocales} availableMicrophones={availableMicrophones} availableSpeakers={availableSpeakers} + backupFeatureEnabled={backupFeatureEnabled} + backupSubscriptionStatus={backupSubscriptionStatus} blockedCount={blockedCount} closeSettings={closeSettings} + cloudBackupStatus={cloudBackupStatus} customColors={customColors} defaultConversationColor={defaultConversationColor} deviceName={deviceName} @@ -221,6 +229,8 @@ SettingsWindowProps.onRender( onWhoCanSeeMeChange={onWhoCanSeeMeChange} onZoomFactorChange={onZoomFactorChange} preferredSystemLocales={preferredSystemLocales} + refreshCloudBackupStatus={refreshCloudBackupStatus} + refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus} removeCustomColorOnConversations={removeCustomColorOnConversations} removeCustomColor={removeCustomColor} resetAllChatColors={resetAllChatColors} diff --git a/ts/windows/settings/preload.ts b/ts/windows/settings/preload.ts index 5df39e32478..4bbed65dc04 100644 --- a/ts/windows/settings/preload.ts +++ b/ts/windows/settings/preload.ts @@ -60,6 +60,18 @@ const settingZoomFactor = createSetting('zoomFactor'); // Getters only. 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', { setter: false, }); @@ -88,6 +100,10 @@ const ipcGetEmojiSkinToneDefault = createCallback('getEmojiSkinToneDefault'); const ipcIsSyncNotSupported = createCallback('isPrimary'); const ipcMakeSyncRequest = createCallback('syncRequest'); const ipcDeleteAllMyStories = createCallback('deleteAllMyStories'); +const ipcRefreshCloudBackupStatus = createCallback('refreshCloudBackupStatus'); +const ipcRefreshBackupSubscriptionStatus = createCallback( + 'refreshBackupSubscriptionStatus' +); // ChatColorPicker redux hookups // The redux actions update over IPC through a preferences re-render @@ -144,7 +160,10 @@ function attachRenderCallback(f: (value: Value) => Promise) { async function renderPreferences() { const { autoDownloadAttachment, + backupFeatureEnabled, + backupSubscriptionStatus, blockedCount, + cloudBackupStatus, deviceName, emojiSkinToneDefault, hasAudioNotifications, @@ -188,7 +207,10 @@ async function renderPreferences() { isSyncNotSupported, } = await awaitObject({ autoDownloadAttachment: settingAutoDownloadAttachment.getValue(), + backupFeatureEnabled: settingBackupFeatureEnabled.getValue(), + backupSubscriptionStatus: settingBackupSubscriptionStatus.getValue(), blockedCount: settingBlockedCount.getValue(), + cloudBackupStatus: settingCloudBackupStatus.getValue(), deviceName: settingDeviceName.getValue(), hasAudioNotifications: settingAudioNotification.getValue(), hasAutoConvertEmoji: settingAutoConvertEmoji.getValue(), @@ -279,7 +301,10 @@ async function renderPreferences() { availableLocales, availableMicrophones, availableSpeakers, + backupFeatureEnabled, + backupSubscriptionStatus, blockedCount, + cloudBackupStatus, customColors, defaultConversationColor, deviceName, @@ -333,12 +358,13 @@ async function renderPreferences() { initialSpellCheckSetting: MinimalSignalContext.config.appStartInitialSpellcheckSetting, makeSyncRequest: ipcMakeSyncRequest, + refreshCloudBackupStatus: ipcRefreshCloudBackupStatus, + refreshBackupSubscriptionStatus: ipcRefreshBackupSubscriptionStatus, removeCustomColor: ipcRemoveCustomColor, removeCustomColorOnConversations: ipcRemoveCustomColorOnConversations, resetAllChatColors: ipcResetAllChatColors, resetDefaultChatColor: ipcResetDefaultChatColor, setGlobalDefaultConversationColor: ipcSetGlobalDefaultConversationColor, - // Limited support features isAutoDownloadUpdatesSupported: Settings.isAutoDownloadUpdatesSupported( OS,