From 2e9dae6b1f81df6f37d3791454eef9729fb8bda2 Mon Sep 17 00:00:00 2001 From: trevor-signal <131492920+trevor-signal@users.noreply.github.com> Date: Wed, 25 Jun 2025 14:56:52 -0400 Subject: [PATCH] Backups: subscription info improvements --- _locales/en/messages.json | 2 +- stylesheets/components/Preferences.scss | 4 ++ ts/components/Preferences.stories.tsx | 14 ++-- ts/components/Preferences.tsx | 14 ++-- ts/components/PreferencesBackups.tsx | 85 +++++++++++++++---------- ts/services/backups/api.ts | 4 +- ts/services/backups/index.ts | 33 +++++----- ts/services/storageRecordOps.ts | 3 +- ts/state/smart/Preferences.tsx | 2 +- ts/textsecure/WebAPI.ts | 37 ++++++++++- ts/types/backups.ts | 9 ++- ts/util/backupSubscriptionData.ts | 17 +++++ 12 files changed, 146 insertions(+), 78 deletions(-) diff --git a/_locales/en/messages.json b/_locales/en/messages.json index d5cc6c6d66..bbf9226cbb 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -6973,7 +6973,7 @@ "description": "Feature name for message backups using the Signal service." }, "icu:Preferences--signal-backups-off-description": { - "messageformat": "Automatic backups with Signal’s secure, end-to-end encrypted storage service. Get started on your phone. Learn more.", + "messageformat": "Automatic backups with Signal’s secure, end-to-end encrypted storage service. Get started on your phone. Learn more.", "description": "Description for message backups using the Signal service." }, "icu:Preferences--backup-section-description": { diff --git a/stylesheets/components/Preferences.scss b/stylesheets/components/Preferences.scss index beda1f9af3..084a67d742 100644 --- a/stylesheets/components/Preferences.scss +++ b/stylesheets/components/Preferences.scss @@ -788,6 +788,10 @@ $secondary-text-color: light-dark( padding-block: 8px; margin-block-start: 8px; + a { + text-decoration: none; + } + &:not(:last-child) { padding-block-end: 24px; } diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 412c8bf584..5680ceb653 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -218,6 +218,7 @@ export default { backupFeatureEnabled: false, backupKeyViewed: false, backupLocalBackupsEnabled: false, + backupSubscriptionStatus: { status: 'off' }, badge: undefined, blockedCount: 0, customColors: {}, @@ -475,9 +476,8 @@ BackupsPaidActive.args = { backupFeatureEnabled: true, backupLocalBackupsEnabled: true, cloudBackupStatus: { - mediaSize: 539_249_410_039, protoSize: 100_000_000, - createdAt: new Date(Date.now() - WEEK).getTime(), + createdTimestamp: Date.now() - WEEK, }, backupSubscriptionStatus: { status: 'active', @@ -485,7 +485,7 @@ BackupsPaidActive.args = { amount: 22.99, currencyCode: 'USD', }, - renewalDate: new Date(Date.now() + 20 * DAY), + renewalTimestamp: Date.now() + 20 * DAY, }, }; @@ -495,9 +495,8 @@ BackupsPaidCancelled.args = { backupFeatureEnabled: true, backupLocalBackupsEnabled: true, cloudBackupStatus: { - mediaSize: 539_249_410_039, protoSize: 100_000_000, - createdAt: new Date(Date.now() - WEEK).getTime(), + createdTimestamp: Date.now() - WEEK, }, backupSubscriptionStatus: { status: 'pending-cancellation', @@ -505,7 +504,7 @@ BackupsPaidCancelled.args = { amount: 22.99, currencyCode: 'USD', }, - expiryDate: new Date(Date.now() + 20 * DAY), + expiryTimestamp: Date.now() + 20 * DAY, }, }; @@ -543,9 +542,8 @@ BackupsSubscriptionNotFound.args = { status: 'not-found', }, cloudBackupStatus: { - mediaSize: 539_249_410_039, protoSize: 100_000_000, - createdAt: new Date(Date.now() - WEEK).getTime(), + createdTimestamp: Date.now() - WEEK, }, }; diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 2702155c95..9cf8f54235 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -116,7 +116,7 @@ export type PropsDataType = { backupLocalBackupsEnabled: boolean; localBackupFolder: string | undefined; cloudBackupStatus?: BackupStatusType; - backupSubscriptionStatus?: BackupsSubscriptionType; + backupSubscriptionStatus: BackupsSubscriptionType; blockedCount: number; customColors: Record; defaultConversationColor: DefaultConversationColorType; @@ -570,8 +570,7 @@ export function Preferences({ setSelectedLanguageLocale(localeOverride); } const shouldShowBackupsPage = - (backupFeatureEnabled && backupSubscriptionStatus != null) || - backupLocalBackupsEnabled; + backupFeatureEnabled || backupLocalBackupsEnabled; if (page === Page.Backups && !shouldShowBackupsPage) { setPage(Page.General); @@ -587,13 +586,6 @@ export function Preferences({ }); } - useEffect(() => { - if (page === Page.Backups) { - refreshCloudBackupStatus(); - refreshBackupSubscriptionStatus(); - } - }, [page, refreshCloudBackupStatus, refreshBackupSubscriptionStatus]); - const onZoomSelectChange = useCallback( (value: string) => { const number = parseFloat(value); @@ -2141,6 +2133,8 @@ export function Preferences({ pickLocalBackupFolder={pickLocalBackupFolder} page={page} promptOSAuth={promptOSAuth} + refreshCloudBackupStatus={refreshCloudBackupStatus} + refreshBackupSubscriptionStatus={refreshBackupSubscriptionStatus} setPage={setPage} showToast={showToast} /> diff --git a/ts/components/PreferencesBackups.tsx b/ts/components/PreferencesBackups.tsx index d09b783d1a..f6924c7557 100644 --- a/ts/components/PreferencesBackups.tsx +++ b/ts/components/PreferencesBackups.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import classNames from 'classnames'; import type { @@ -44,12 +44,14 @@ export function PreferencesBackups({ pickLocalBackupFolder, page, promptOSAuth, + refreshCloudBackupStatus, + refreshBackupSubscriptionStatus, setPage, showToast, }: { accountEntropyPool: string | undefined; backupKeyViewed: boolean; - backupSubscriptionStatus?: BackupsSubscriptionType; + backupSubscriptionStatus: BackupsSubscriptionType; cloudBackupStatus?: BackupStatusType; localBackupFolder: string | undefined; i18n: LocalizerType; @@ -60,14 +62,29 @@ export function PreferencesBackups({ promptOSAuth: ( reason: PromptOSAuthReasonType ) => Promise; + refreshCloudBackupStatus: () => void; + refreshBackupSubscriptionStatus: () => void; setPage: (page: PreferencesBackupPage) => void; showToast: ShowToastAction; -}): JSX.Element { +}): JSX.Element | null { const [authError, setAuthError] = useState>(); const [isAuthPending, setIsAuthPending] = useState(false); + useEffect(() => { + if (page === Page.Backups) { + refreshBackupSubscriptionStatus(); + } else if (page === Page.BackupsDetails) { + refreshBackupSubscriptionStatus(); + refreshCloudBackupStatus(); + } + }, [page, refreshBackupSubscriptionStatus, refreshCloudBackupStatus]); + if (page === Page.BackupsDetails) { + if (backupSubscriptionStatus.status === 'off') { + setPage(Page.Backups); + return null; + } return ( - {backupSubscriptionStatus ? ( + {backupSubscriptionStatus.status === 'off' ? ( + + + {i18n('icu:Preferences--signal-backups')}{' '} +
+ +
+ + } + right={null} + /> +
+ ) : (
@@ -149,27 +187,6 @@ export function PreferencesBackups({
- ) : ( - - - {i18n('icu:Preferences--signal-backups')}{' '} -
- -
- - } - right={null} - /> -
)} ) : null} - {subscriptionStatus.renewalDate ? ( + {subscriptionStatus.renewalTimestamp ? (
{i18n('icu:Preferences--backup-plan__renewal-date', { - date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), { + date: formatTimestamp(subscriptionStatus.renewalTimestamp, { dateStyle: 'medium', }), })} @@ -282,10 +299,10 @@ function getSubscriptionDetails({
{i18n('icu:Preferences--backup-plan__canceled')}
- {subscriptionStatus.expiryDate ? ( + {subscriptionStatus.expiryTimestamp ? (
{i18n('icu:Preferences--backup-plan__expiry-date', { - date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), { + date: formatTimestamp(subscriptionStatus.expiryTimestamp, { dateStyle: 'medium', }), })} @@ -313,6 +330,8 @@ export function renderBackupsSubscriptionDetails({ const { status } = subscriptionStatus; switch (status) { + case 'off': + return null; case 'active': case 'pending-cancellation': return ( @@ -398,6 +417,8 @@ export function renderBackupsSubscriptionSummary({ const { status } = subscriptionStatus; switch (status) { + case 'off': + return null; case 'active': case 'pending-cancellation': return ( @@ -449,7 +470,7 @@ function BackupsDetailsPage({ locale, }: { cloudBackupStatus?: BackupStatusType; - backupSubscriptionStatus?: BackupsSubscriptionType; + backupSubscriptionStatus: BackupsSubscriptionType; i18n: LocalizerType; locale: string; }): JSX.Element { @@ -468,7 +489,7 @@ function BackupsDetailsPage({ className="Preferences--backup-details" title={i18n('icu:Preferences--backup-details__header')} > - {cloudBackupStatus.createdAt ? ( + {cloudBackupStatus.createdTimestamp ? (
- {formatTimestamp(cloudBackupStatus.createdAt, { + {formatTimestamp(cloudBackupStatus.createdTimestamp, { dateStyle: 'medium', timeStyle: 'short', })} diff --git a/ts/services/backups/api.ts b/ts/services/backups/api.ts index 34ae9fe86c..741f81ba06 100644 --- a/ts/services/backups/api.ts +++ b/ts/services/backups/api.ts @@ -253,14 +253,14 @@ export class BackupAPI { return { status: 'pending-cancellation', cost, - expiryDate: endOfCurrentPeriod, + expiryTimestamp: endOfCurrentPeriod?.getTime(), }; } return { status: 'active', cost, - renewalDate: endOfCurrentPeriod, + renewalTimestamp: endOfCurrentPeriod?.getTime(), }; } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index 545666d760..e73731e4a9 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -27,7 +27,7 @@ import { prependStream } from '../../util/prependStream'; import { appendMacStream } from '../../util/appendMacStream'; import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac'; import { missingCaseError } from '../../util/missingCaseError'; -import { DAY, HOUR, MINUTE } from '../../util/durations'; +import { DAY, HOUR, SECOND } from '../../util/durations'; import type { ExplodePromiseResultType } from '../../util/explodePromise'; import { explodePromise } from '../../util/explodePromise'; import type { RetryBackupImportValue } from '../../state/ducks/installer'; @@ -153,11 +153,11 @@ export class BackupsService { public readonly credentials = new BackupCredentials(); public readonly api = new BackupAPI(this.credentials); public readonly throttledFetchCloudBackupStatus = throttle( - MINUTE, + 30 * SECOND, this.fetchCloudBackupStatus.bind(this) ); public readonly throttledFetchSubscriptionStatus = throttle( - MINUTE, + 30 * SECOND, this.fetchSubscriptionStatus.bind(this) ); @@ -986,8 +986,7 @@ export class BackupsService { } catch (error) { log.error('Backup: periodic refresh failed', Errors.toLogFormat(error)); } - drop(this.fetchCloudBackupStatus()); - drop(this.fetchSubscriptionStatus()); + await this.refreshBackupAndSubscriptionStatus(); } async #unlinkAndDeleteAllData() { @@ -1064,24 +1063,15 @@ export class BackupsService { } } - 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(), - ]); + const backupProtoInfo = await this.api.getBackupProtoInfo(); if (backupProtoInfo.backupExists) { const { createdAt, size: protoSize } = backupProtoInfo; result = { - createdAt: createdAt.getTime(), + createdTimestamp: createdAt.getTime(), protoSize, - mediaSize, }; } @@ -1097,6 +1087,10 @@ export class BackupsService { switch (backupTier) { case null: case undefined: + result = { + status: 'off', + }; + break; case BackupLevel.Free: result = { status: 'free', @@ -1114,6 +1108,13 @@ export class BackupsService { return result; } + async refreshBackupAndSubscriptionStatus(): Promise { + await Promise.all([ + this.fetchSubscriptionStatus(), + this.fetchCloudBackupStatus(), + ]); + } + hasMediaBackups(): boolean { return window.storage.get('backupTier') === BackupLevel.Paid; } diff --git a/ts/services/storageRecordOps.ts b/ts/services/storageRecordOps.ts index 0b34193e3a..0be5804c9a 100644 --- a/ts/services/storageRecordOps.ts +++ b/ts/services/storageRecordOps.ts @@ -79,6 +79,7 @@ import { callLinkRefreshJobQueue } from '../jobs/callLinkRefreshJobQueue'; import { generateBackupsSubscriberData, saveBackupsSubscriberData, + saveBackupTier, } from '../util/backupSubscriptionData'; import { toAciObject, @@ -1601,7 +1602,7 @@ export async function mergeAccountRecord( } await saveBackupsSubscriberData(backupSubscriberData); - await window.storage.put('backupTier', backupTier?.toNumber()); + await saveBackupTier(backupTier?.toNumber()); await window.storage.put( 'displayBadgesOnProfile', diff --git a/ts/state/smart/Preferences.tsx b/ts/state/smart/Preferences.tsx index 47052209e1..467f581a7c 100644 --- a/ts/state/smart/Preferences.tsx +++ b/ts/state/smart/Preferences.tsx @@ -692,7 +692,7 @@ export function SmartPreferences(): JSX.Element | null { availableSpeakers={availableSpeakers} backupFeatureEnabled={backupFeatureEnabled} backupKeyViewed={backupKeyViewed} - backupSubscriptionStatus={backupSubscriptionStatus} + backupSubscriptionStatus={backupSubscriptionStatus ?? { status: 'off' }} backupLocalBackupsEnabled={backupLocalBackupsEnabled} badge={badge} blockedCount={blockedCount} diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index adee026fa6..95d86c390e 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1414,17 +1414,50 @@ const backupFileHeadersSchema = z.object({ type BackupFileHeadersType = z.infer; +// See: https://docs.stripe.com/currencies?presentment-currency=US +const ZERO_DECIMAL_CURRENCIES = new Set([ + 'bif', + 'clp', + 'djf', + 'gnf', + 'jpy', + 'kmf', + 'krw', + 'mga', + 'pyg', + 'rwf', + 'vnd', + 'vuv', + 'xaf', + 'xof', + 'xpf', +]); +const secondsTimestampToDate = z.coerce + .number() + .transform(sec => new Date(sec * 1_000)); + const subscriptionResponseSchema = z.object({ subscription: z .object({ level: z.number(), - billingCycleAnchor: z.coerce.date().optional(), - endOfCurrentPeriod: z.coerce.date().optional(), + billingCycleAnchor: secondsTimestampToDate.optional(), + endOfCurrentPeriod: secondsTimestampToDate.optional(), active: z.boolean(), cancelAtPeriodEnd: z.boolean().optional(), currency: z.string().optional(), amount: z.number().nonnegative().optional(), }) + .transform(data => { + const result = { ...data }; + if (result.currency && result.amount) { + result.amount = ZERO_DECIMAL_CURRENCIES.has( + result.currency.toLowerCase() + ) + ? result.amount + : result.amount / 100; + } + return result; + }) .nullish(), }); diff --git a/ts/types/backups.ts b/ts/types/backups.ts index 9120a3ed32..3bf80b64cb 100644 --- a/ts/types/backups.ts +++ b/ts/types/backups.ts @@ -36,14 +36,13 @@ export type SubscriptionCostType = { }; export type BackupStatusType = { - createdAt?: number; + createdTimestamp?: number; protoSize?: number; - mediaSize?: number; }; export type BackupsSubscriptionType = | { - status: 'not-found' | 'expired'; + status: 'off' | 'not-found' | 'expired'; } | { status: 'free'; @@ -52,12 +51,12 @@ export type BackupsSubscriptionType = | ( | { status: 'active'; - renewalDate?: Date; + renewalTimestamp?: number; cost?: SubscriptionCostType; } | { status: 'pending-cancellation'; - expiryDate?: Date; + expiryTimestamp?: number; cost?: SubscriptionCostType; } ); diff --git a/ts/util/backupSubscriptionData.ts b/ts/util/backupSubscriptionData.ts index baf0e30541..9f61624388 100644 --- a/ts/util/backupSubscriptionData.ts +++ b/ts/util/backupSubscriptionData.ts @@ -4,6 +4,7 @@ import Long from 'long'; import type { Backups, SignalService } from '../protobuf'; import * as Bytes from '../Bytes'; +import { drop } from './drop'; // These two proto messages (Backups.AccountData.IIAPSubscriberData && // SignalService.AccountRecord.IIAPSubscriberData) should remain in sync. If they drift, @@ -15,6 +16,12 @@ export async function saveBackupsSubscriberData( | null | undefined ): Promise { + const previousSubscriberId = window.storage.get('backupsSubscriberId'); + + if (previousSubscriberId !== backupsSubscriberData?.subscriberId) { + drop(window.Signal.Services.backups.refreshBackupAndSubscriptionStatus()); + } + if (backupsSubscriberData == null) { await window.storage.remove('backupsSubscriberId'); await window.storage.remove('backupsSubscriberPurchaseToken'); @@ -47,6 +54,16 @@ export async function saveBackupsSubscriberData( } } +export async function saveBackupTier( + backupTier: number | undefined +): Promise { + const previousBackupTier = window.storage.get('backupTier'); + await window.storage.put('backupTier', backupTier); + if (backupTier !== previousBackupTier) { + drop(window.Signal.Services.backups.refreshBackupAndSubscriptionStatus()); + } +} + export function generateBackupsSubscriberData(): Backups.AccountData.IIAPSubscriberData | null { const backupsSubscriberId = window.storage.get('backupsSubscriberId');