Refactor backup subscription UI

This commit is contained in:
trevor-signal
2025-10-15 11:05:59 -04:00
committed by GitHub
parent dbaf2f5e68
commit 6ec7272d4e
14 changed files with 481 additions and 320 deletions

View File

@@ -7513,7 +7513,7 @@
"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 Secure Backups.",
"messageformat": "Your subscription was not found. Check your phone to view your backup details.",
"description": "Description when a backup subscription used to exist but is not active"
},
"icu:Preferences--backup-subscription-monthly-cost": {

View File

@@ -232,8 +232,11 @@ export function isEnabled(
return get(reduxConfig ?? config, [name, 'enabled'], false);
}
export function getValue(name: ConfigKeyType): string | undefined {
return get(config, [name, 'value']);
export function getValue(
name: ConfigKeyType, // when called from UI component, provide redux config (items.remoteConfig)
reduxConfig?: ConfigMapType
): string | undefined {
return get(reduxConfig ?? config, [name, 'value']);
}
// See isRemoteConfigBucketEnabled in selectors/items.ts

View File

@@ -12,7 +12,7 @@ import { DEFAULT_CONVERSATION_COLOR } from '../types/Colors.js';
import { PhoneNumberSharingMode } from '../types/PhoneNumberSharingMode.js';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability.js';
import { EmojiSkinTone } from './fun/data/emojis.js';
import { DAY, DurationInSeconds, WEEK } from '../util/durations/index.js';
import { DAY, DurationInSeconds, HOUR, WEEK } from '../util/durations/index.js';
import { DialogUpdate } from './DialogUpdate.js';
import { DialogType } from '../types/Dialogs.js';
import { ThemeType } from '../types/Util.js';
@@ -53,6 +53,7 @@ import type { SmartPreferencesEditChatFolderPageProps } from '../state/smart/Pre
import { CurrentChatFolders } from '../types/CurrentChatFolders.js';
import type { ExternalProps as SmartNotificationProfilesProps } from '../state/smart/PreferencesNotificationProfiles.js';
import type { NotificationProfileIdString } from '../types/NotificationProfile.js';
import { BackupLevel } from '../services/backups/types.js';
const { shuffle } = lodash;
@@ -403,9 +404,11 @@ export default {
availableMicrophones,
availableSpeakers,
backupFeatureEnabled: false,
backupFreeMediaDays: 45,
backupKeyViewed: false,
backupLocalBackupsEnabled: false,
backupSubscriptionStatus: { status: 'off' },
backupSubscriptionStatus: { status: 'not-found' },
backupTier: null,
badge: undefined,
blockedCount: 0,
currentChatFoldersCount: 0,
@@ -965,8 +968,8 @@ PNPDiscoverabilityDisabled.args = {
settingsLocation: { page: SettingsPage.PNP },
};
export const BackupsMediaDownloadActive = Template.bind({});
BackupsMediaDownloadActive.args = {
export const BackupDetailsMediaDownloadActive = Template.bind({});
BackupDetailsMediaDownloadActive.args = {
settingsLocation: { page: SettingsPage.BackupsDetails },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
@@ -974,6 +977,7 @@ BackupsMediaDownloadActive.args = {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
},
backupTier: BackupLevel.Paid,
backupSubscriptionStatus: {
status: 'active',
cost: {
@@ -989,8 +993,8 @@ BackupsMediaDownloadActive.args = {
isIdle: false,
},
};
export const BackupsMediaDownloadPaused = Template.bind({});
BackupsMediaDownloadPaused.args = {
export const BackupDetailsMediaDownloadPaused = Template.bind({});
BackupDetailsMediaDownloadPaused.args = {
settingsLocation: { page: SettingsPage.BackupsDetails },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
@@ -998,6 +1002,7 @@ BackupsMediaDownloadPaused.args = {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
},
backupTier: BackupLevel.Paid,
backupSubscriptionStatus: {
status: 'active',
cost: {
@@ -1014,9 +1019,26 @@ BackupsMediaDownloadPaused.args = {
},
};
export const BackupDetailsFree = Template.bind({});
BackupDetailsFree.args = {
settingsLocation: { page: SettingsPage.BackupsDetails },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
},
backupTier: BackupLevel.Free,
backupSubscriptionStatus: {
status: 'not-found',
lastFetchedAtMs: Date.now(),
},
};
export const BackupsPaidActive = Template.bind({});
BackupsPaidActive.args = {
settingsLocation: { page: SettingsPage.Backups },
backupTier: BackupLevel.Paid,
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
@@ -1033,11 +1055,50 @@ BackupsPaidActive.args = {
},
};
export const BackupsPaidLoadingSubscription = Template.bind({});
BackupsPaidLoadingSubscription.args = {
settingsLocation: { page: SettingsPage.Backups },
backupTier: BackupLevel.Paid,
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
},
backupSubscriptionStatus: {
status: 'active',
cost: {
amount: 22.99,
currencyCode: 'USD',
},
renewalTimestamp: Date.now() + 20 * DAY,
isFetching: true,
lastFetchedAtMs: Date.now() - HOUR,
},
};
export const BackupsPaidLoadingFirstTime = Template.bind({});
BackupsPaidLoadingFirstTime.args = {
settingsLocation: { page: SettingsPage.Backups },
backupTier: BackupLevel.Paid,
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
cloudBackupStatus: {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
},
backupSubscriptionStatus: {
status: 'not-found',
isFetching: true,
},
};
export const BackupsPaidCanceled = Template.bind({});
BackupsPaidCanceled.args = {
settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
backupTier: BackupLevel.Paid,
cloudBackupStatus: {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
@@ -1055,22 +1116,16 @@ BackupsPaidCanceled.args = {
export const BackupsFree = Template.bind({});
BackupsFree.args = {
settingsLocation: { page: SettingsPage.Backups },
backupTier: BackupLevel.Free,
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
backupSubscriptionStatus: {
status: 'free',
mediaIncludedInBackupDurationDays: 30,
},
};
export const BackupsFreeNoLocal = Template.bind({});
BackupsFreeNoLocal.args = {
settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: false,
backupSubscriptionStatus: {
status: 'free',
mediaIncludedInBackupDurationDays: 30,
},
backupTier: BackupLevel.Free,
};
export const BackupsOff = Template.bind({});
@@ -1078,6 +1133,7 @@ BackupsOff.args = {
settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
backupTier: null,
};
export const BackupsLocalBackups = Template.bind({});
@@ -1094,14 +1150,15 @@ BackupsRemoteEnabledLocalDisabled.args = {
backupLocalBackupsEnabled: false,
};
export const BackupsSubscriptionNotFound = Template.bind({});
BackupsSubscriptionNotFound.args = {
export const BackupsPaidSubscriptionNotFound = Template.bind({});
BackupsPaidSubscriptionNotFound.args = {
settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
backupSubscriptionStatus: {
status: 'not-found',
},
backupTier: BackupLevel.Paid,
cloudBackupStatus: {
protoSize: 100_000_000,
createdTimestamp: Date.now() - WEEK,
@@ -1113,6 +1170,7 @@ BackupsSubscriptionExpired.args = {
settingsLocation: { page: SettingsPage.Backups },
backupFeatureEnabled: true,
backupLocalBackupsEnabled: true,
backupTier: null,
backupSubscriptionStatus: {
status: 'expired',
},

View File

@@ -15,6 +15,8 @@ import classNames from 'classnames';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
import type { MutableRefObject, ReactNode } from 'react';
import type { RowType } from '@signalapp/sqlcipher';
import type { BackupLevel } from '@signalapp/libsignal-client/zkgroup.js';
import { Button, ButtonVariant } from './Button.js';
import { ChatColorPicker } from './ChatColorPicker.js';
import { Checkbox } from './Checkbox.js';
@@ -111,8 +113,10 @@ export type PropsDataType = {
accountEntropyPool: string | undefined;
autoDownloadAttachment: AutoDownloadAttachmentType;
backupFeatureEnabled: boolean;
backupFreeMediaDays: number;
backupKeyViewed: boolean;
backupLocalBackupsEnabled: boolean;
backupTier: BackupLevel | null;
localBackupFolder: string | undefined;
currentChatFoldersCount: number;
cloudBackupStatus?: BackupStatusType;
@@ -383,7 +387,9 @@ export function Preferences({
pauseBackupMediaDownload,
resumeBackupMediaDownload,
cancelBackupMediaDownload,
backupFreeMediaDays,
backupKeyViewed,
backupTier,
backupSubscriptionStatus,
backupLocalBackupsEnabled,
badge,
@@ -2188,7 +2194,9 @@ export function Preferences({
const pageContents = (
<PreferencesBackups
accountEntropyPool={accountEntropyPool}
backupFreeMediaDays={backupFreeMediaDays}
backupKeyViewed={backupKeyViewed}
backupTier={backupTier}
backupSubscriptionStatus={backupSubscriptionStatus}
backupMediaDownloadStatus={backupMediaDownloadStatus}
cancelBackupMediaDownload={cancelBackupMediaDownload}

View File

@@ -0,0 +1,287 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type {
BackupMediaDownloadStatusType,
BackupsSubscriptionType,
BackupStatusType,
} from '../types/backups.js';
import type { LocalizerType } from '../types/I18N.js';
import { formatTimestamp } from '../util/formatTimestamp.js';
import { SettingsRow } from './PreferencesUtil.js';
import { missingCaseError } from '../util/missingCaseError.js';
import { BackupMediaDownloadProgressSettings } from './BackupMediaDownloadProgressSettings.js';
import { BackupLevel } from '../services/backups/types.js';
import { SpinnerV2 } from './SpinnerV2.js';
import { MINUTE } from '../util/durations/constants.js';
import { isOlderThan } from '../util/timestamp.js';
// We'll show a loading spinner if we are fetching fresh data and cached data is older
// than this duration
const SUBSCRIPTION_STATUS_STALE_TIME_FOR_UI = 5 * MINUTE;
export function BackupsDetailsPage({
cloudBackupStatus,
backupFreeMediaDays,
backupSubscriptionStatus,
backupTier,
i18n,
locale,
cancelBackupMediaDownload,
pauseBackupMediaDownload,
resumeBackupMediaDownload,
backupMediaDownloadStatus,
}: {
cloudBackupStatus?: BackupStatusType;
backupFreeMediaDays: number;
backupSubscriptionStatus: BackupsSubscriptionType;
backupTier: BackupLevel | null;
i18n: LocalizerType;
locale: string;
cancelBackupMediaDownload: () => void;
pauseBackupMediaDownload: () => void;
resumeBackupMediaDownload: () => void;
backupMediaDownloadStatus?: BackupMediaDownloadStatusType;
}): JSX.Element {
const shouldShowMediaProgress =
backupMediaDownloadStatus &&
backupMediaDownloadStatus.completedBytes <
backupMediaDownloadStatus.totalBytes;
return (
<>
<div className="Preferences--backups-summary__container">
{backupTier === BackupLevel.Paid
? renderPaidBackupDetailsSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})
: null}
{backupTier === BackupLevel.Free
? renderFreeBackupDetailsSummary({
backupFreeMediaDays,
i18n,
})
: null}
</div>
{cloudBackupStatus || shouldShowMediaProgress ? (
<SettingsRow
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus?.createdTimestamp ? (
<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.createdTimestamp, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
) : null}
{shouldShowMediaProgress && backupMediaDownloadStatus ? (
<div className="Preferences--backup-details__row">
<BackupMediaDownloadProgressSettings
{...backupMediaDownloadStatus}
handleCancel={cancelBackupMediaDownload}
handlePause={pauseBackupMediaDownload}
handleResume={resumeBackupMediaDownload}
i18n={i18n}
/>
</div>
) : null}
</SettingsRow>
) : null}
</>
);
}
function renderPaidBackupDetailsSummary({
subscriptionStatus,
i18n,
locale,
}: {
locale: string;
subscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
}): JSX.Element | null {
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">
{subscriptionStatus
? renderSubscriptionDetails({ i18n, locale, subscriptionStatus })
: null}
</div>
</div>
{getSubscriptionStatusIcon(subscriptionStatus)}
</div>
<div className="Preferences--backups-summary__note">
{getSubscriptionNote(i18n, subscriptionStatus)}
</div>
</>
);
}
function getSubscriptionNote(
i18n: LocalizerType,
subscriptionStatus: BackupsSubscriptionType | undefined
) {
const status = subscriptionStatus?.status;
switch (status) {
case 'active':
case 'pending-cancellation':
return i18n('icu:Preferences--backup-media-plan__note');
case 'not-found':
case 'expired':
case undefined:
return i18n('icu:Preferences--backup-plan__not-found__note');
default:
throw missingCaseError(status);
}
}
function getSubscriptionStatusIcon(
subscriptionStatus: BackupsSubscriptionType | undefined
) {
const status = subscriptionStatus?.status;
switch (status) {
case 'active':
return (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--active" />
);
case 'pending-cancellation':
case 'not-found':
case 'expired':
case undefined:
return (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--inactive" />
);
default:
throw missingCaseError(status);
}
}
function renderFreeBackupDetailsSummary({
backupFreeMediaDays,
i18n,
}: {
backupFreeMediaDays: number;
i18n: LocalizerType;
}): JSX.Element | null {
return (
<>
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-messages-plan__description', {
mediaDayCount: backupFreeMediaDays,
})}
</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>
</>
);
}
export function renderSubscriptionDetails({
i18n,
subscriptionStatus,
locale,
}: {
i18n: LocalizerType;
locale: string;
subscriptionStatus: BackupsSubscriptionType;
}): JSX.Element | null {
const { status } = subscriptionStatus;
if (
subscriptionStatus.isFetching &&
isOlderThan(
subscriptionStatus.lastFetchedAtMs ?? 0,
SUBSCRIPTION_STATUS_STALE_TIME_FOR_UI
)
) {
return (
<SpinnerV2 variant="no-background-light" size={24} strokeWidth={3} />
);
}
switch (status) {
case 'active':
return (
<>
{subscriptionStatus.cost ? (
<div className="Preferences--backups-summary__subscription-price">
{i18n('icu:Preferences--backup-subscription-monthly-cost', {
cost: new Intl.NumberFormat(locale, {
style: 'currency',
currency: subscriptionStatus.cost.currencyCode,
currencyDisplay: 'narrowSymbol',
}).format(subscriptionStatus.cost.amount),
})}
</div>
) : null}
{subscriptionStatus.renewalTimestamp ? (
<div className="Preferences--backups-summary__renewal-date">
{i18n('icu:Preferences--backup-plan__renewal-date', {
date: formatTimestamp(subscriptionStatus.renewalTimestamp, {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
case 'pending-cancellation':
return (
<>
<div className="Preferences--backups-summary__canceled">
{i18n('icu:Preferences--backup-plan__canceled')}
</div>
{subscriptionStatus.expiryTimestamp ? (
<div className="Preferences--backups-summary__expiry-date">
{i18n('icu:Preferences--backup-plan__expiry-date', {
date: formatTimestamp(subscriptionStatus.expiryTimestamp, {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
case 'not-found':
case 'expired':
return (
<div className="Preferences--backups-summary__status-container">
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-plan-not-found__description')}
</div>
</div>
);
default:
throw missingCaseError(status);
}
}

View File

@@ -10,14 +10,12 @@ import type {
BackupStatusType,
} from '../types/backups.js';
import type { LocalizerType } from '../types/I18N.js';
import { formatTimestamp } from '../util/formatTimestamp.js';
import {
SettingsControl as Control,
FlowingSettingsControl as FlowingControl,
LightIconLabel,
SettingsRow,
} from './PreferencesUtil.js';
import { missingCaseError } from '../util/missingCaseError.js';
import { Button, ButtonVariant } from './Button.js';
import type { SettingsLocation } from '../types/Nav.js';
import { SettingsPage } from '../types/Nav.js';
@@ -29,7 +27,11 @@ import type {
PromptOSAuthResultType,
} from '../util/os/promptOSAuthMain.js';
import { ConfirmationDialog } from './ConfirmationDialog.js';
import { BackupMediaDownloadProgressSettings } from './BackupMediaDownloadProgressSettings.js';
import { BackupLevel } from '../services/backups/types.js';
import {
BackupsDetailsPage,
renderSubscriptionDetails,
} from './PreferencesBackupDetails.js';
export const SIGNAL_BACKUPS_LEARN_MORE_URL =
'https://support.signal.org/hc/articles/360007059752-Backup-and-Restore-Messages';
@@ -50,8 +52,10 @@ function isRemoteBackupsPage(page: SettingsPage) {
}
export function PreferencesBackups({
accountEntropyPool,
backupFreeMediaDays,
backupKeyViewed,
backupSubscriptionStatus,
backupTier,
cloudBackupStatus,
i18n,
isLocalBackupsEnabled,
@@ -72,8 +76,10 @@ export function PreferencesBackups({
showToast,
}: {
accountEntropyPool: string | undefined;
backupFreeMediaDays: number;
backupKeyViewed: boolean;
backupSubscriptionStatus: BackupsSubscriptionType;
backupTier: BackupLevel | null;
cloudBackupStatus?: BackupStatusType;
localBackupFolder: string | undefined;
i18n: LocalizerType;
@@ -123,7 +129,7 @@ export function PreferencesBackups({
}
if (settingsLocation.page === SettingsPage.BackupsDetails) {
if (backupSubscriptionStatus.status === 'off') {
if (backupTier == null) {
setSettingsLocation({ page: SettingsPage.Backups });
return null;
}
@@ -131,6 +137,8 @@ export function PreferencesBackups({
<BackupsDetailsPage
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupTier={backupTier}
backupFreeMediaDays={backupFreeMediaDays}
backupSubscriptionStatus={backupSubscriptionStatus}
backupMediaDownloadStatus={backupMediaDownloadStatus}
cancelBackupMediaDownload={cancelBackupMediaDownload}
@@ -169,7 +177,7 @@ export function PreferencesBackups({
function renderRemoteBackups() {
return (
<>
{backupSubscriptionStatus.status === 'off' ? (
{backupTier == null ? (
<SettingsRow className="Preferences--BackupsRow">
<Control
icon="Preferences__BackupsIcon"
@@ -196,13 +204,21 @@ export function PreferencesBackups({
<div className="Preferences__two-thirds-flow">
<LightIconLabel icon="Preferences__BackupsIcon">
<label>
{i18n('icu:Preferences--signal-backups')}{' '}
{i18n('icu:Preferences--signal-backups')}
<div className="Preferences__description">
{renderBackupsSubscriptionSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
{backupTier === BackupLevel.Paid
? renderPaidBackupsSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})
: null}
{backupTier === BackupLevel.Free
? renderFreeBackupsSummary({
i18n,
backupFreeMediaDays,
})
: null}
</div>
</label>
</LightIconLabel>
@@ -317,281 +333,49 @@ export function PreferencesBackups({
);
}
function getSubscriptionDetails({
i18n,
export function renderPaidBackupsSummary({
subscriptionStatus,
i18n,
locale,
}: {
i18n: LocalizerType;
locale: string;
subscriptionStatus: BackupsSubscriptionType;
}): JSX.Element | null {
if (subscriptionStatus.status === 'active') {
return (
<>
{subscriptionStatus.cost ? (
<div className="Preferences--backups-summary__subscription-price">
{i18n('icu:Preferences--backup-subscription-monthly-cost', {
cost: new Intl.NumberFormat(locale, {
style: 'currency',
currency: subscriptionStatus.cost.currencyCode,
currencyDisplay: 'narrowSymbol',
}).format(subscriptionStatus.cost.amount),
})}
</div>
) : null}
{subscriptionStatus.renewalTimestamp ? (
<div className="Preferences--backups-summary__renewal-date">
{i18n('icu:Preferences--backup-plan__renewal-date', {
date: formatTimestamp(subscriptionStatus.renewalTimestamp, {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
if (subscriptionStatus.status === 'pending-cancellation') {
return (
<>
<div className="Preferences--backups-summary__canceled">
{i18n('icu:Preferences--backup-plan__canceled')}
</div>
{subscriptionStatus.expiryTimestamp ? (
<div className="Preferences--backups-summary__expiry-date">
{i18n('icu:Preferences--backup-plan__expiry-date', {
date: formatTimestamp(subscriptionStatus.expiryTimestamp, {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
return null;
}
export function renderBackupsSubscriptionDetails({
subscriptionStatus,
i18n,
locale,
}: {
locale: string;
subscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
}): JSX.Element | null {
if (!subscriptionStatus) {
return null;
}
const { status } = subscriptionStatus;
switch (status) {
case 'off':
return null;
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);
}
}
export function renderBackupsSubscriptionSummary({
subscriptionStatus,
i18n,
locale,
}: {
locale: string;
subscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
}): JSX.Element | null {
if (!subscriptionStatus) {
return null;
}
const { status } = subscriptionStatus;
switch (status) {
case 'off':
return null;
case 'active':
case 'pending-cancellation':
return (
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-media-plan__description')}
</div>
<div className="Preferences--backups-summary__content">
{getSubscriptionDetails({ i18n, locale, subscriptionStatus })}
</div>
</div>
</div>
);
case 'free':
return (
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-messages-plan__description', {
mediaDayCount:
subscriptionStatus.mediaIncludedInBackupDurationDays,
})}
</div>
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-messages-plan__cost-description')}
</div>
</div>
</div>
);
case 'not-found':
case 'expired':
return (
<div className="Preferences--backups-summary__status-container">
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-plan-not-found__description')}
</div>
</div>
);
default:
throw missingCaseError(status);
}
}
function BackupsDetailsPage({
cloudBackupStatus,
backupSubscriptionStatus,
i18n,
locale,
cancelBackupMediaDownload,
pauseBackupMediaDownload,
resumeBackupMediaDownload,
backupMediaDownloadStatus,
}: {
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus: BackupsSubscriptionType;
i18n: LocalizerType;
locale: string;
cancelBackupMediaDownload: () => void;
pauseBackupMediaDownload: () => void;
resumeBackupMediaDownload: () => void;
backupMediaDownloadStatus?: BackupMediaDownloadStatusType;
}): JSX.Element {
const shouldShowMediaProgress =
backupMediaDownloadStatus &&
backupMediaDownloadStatus.completedBytes <
backupMediaDownloadStatus.totalBytes;
return (
<>
<div className="Preferences--backups-summary__container">
{renderBackupsSubscriptionDetails({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
<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">
{renderSubscriptionDetails({ i18n, locale, subscriptionStatus })}
</div>
</div>
</div>
);
}
{cloudBackupStatus || shouldShowMediaProgress ? (
<SettingsRow
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus?.createdTimestamp ? (
<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.createdTimestamp, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
) : null}
{shouldShowMediaProgress && backupMediaDownloadStatus ? (
<div className="Preferences--backup-details__row">
<BackupMediaDownloadProgressSettings
{...backupMediaDownloadStatus}
handleCancel={cancelBackupMediaDownload}
handlePause={pauseBackupMediaDownload}
handleResume={resumeBackupMediaDownload}
i18n={i18n}
/>
</div>
) : null}
</SettingsRow>
) : null}
</>
export function renderFreeBackupsSummary({
backupFreeMediaDays,
i18n,
}: {
backupFreeMediaDays: number;
i18n: LocalizerType;
}): JSX.Element | null {
return (
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-messages-plan__description', {
mediaDayCount: backupFreeMediaDays,
})}
</div>
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-messages-plan__cost-description')}
</div>
</div>
</div>
);
}

View File

@@ -35,6 +35,10 @@ const SpinnerVariants = {
bg: tw('stroke-none'),
fg: tw('stroke-label-primary'),
},
'no-background-light': {
bg: tw('stroke-none'),
fg: tw('stroke-border-primary'),
},
brand: {
bg: tw('stroke-fill-secondary'),
fg: tw('stroke-border-selected'),

View File

@@ -224,7 +224,7 @@ export class BackupAPI {
public async getSubscriptionInfo(): Promise<BackupsSubscriptionType> {
const subscriberId = itemStorage.get('backupsSubscriberId');
if (!subscriberId) {
log.error('Backups.getSubscriptionInfo: missing subscriberId');
log.warn('Backups.getSubscriptionInfo: missing subscriberId');
return { status: 'not-found' };
}

View File

@@ -34,7 +34,7 @@ import { prependStream } from '../../util/prependStream.js';
import { appendMacStream } from '../../util/appendMacStream.js';
import { getMacAndUpdateHmac } from '../../util/getMacAndUpdateHmac.js';
import { missingCaseError } from '../../util/missingCaseError.js';
import { DAY, HOUR, SECOND } from '../../util/durations/index.js';
import { HOUR, SECOND } from '../../util/durations/index.js';
import type { ExplodePromiseResultType } from '../../util/explodePromise.js';
import { explodePromise } from '../../util/explodePromise.js';
import type { RetryBackupImportValue } from '../../state/ducks/installer.js';
@@ -80,7 +80,6 @@ import {
import { FileStream } from './util/FileStream.js';
import { ToastType } from '../../types/Toast.js';
import { isAdhoc, isNightly } from '../../util/version.js';
import { getMessageQueueTime } from '../../util/getMessageQueueTime.js';
import { isLocalBackupsEnabled } from '../../util/isLocalBackupsEnabled.js';
import type { ValidateLocalBackupStructureResultType } from './util/localBackup.js';
import {
@@ -1096,29 +1095,33 @@ export class BackupsService {
async #fetchSubscriptionStatus(): Promise<
BackupsSubscriptionType | undefined
> {
const cachedBackupSubscriptionStatus = itemStorage.get(
'backupSubscriptionStatus'
);
const backupTier = this.#getBackupTierFromStorage();
let result: BackupsSubscriptionType;
let result: BackupsSubscriptionType | undefined;
switch (backupTier) {
case null:
case undefined:
result = {
status: 'off',
};
break;
case BackupLevel.Free:
result = {
status: 'free',
mediaIncludedInBackupDurationDays: getMessageQueueTime() / DAY,
};
result = { status: 'not-found' };
break;
case BackupLevel.Paid:
await itemStorage.put('backupSubscriptionStatus', {
...(cachedBackupSubscriptionStatus ?? { status: 'not-found' }),
isFetching: true,
});
result = await this.api.getSubscriptionInfo();
break;
default:
throw missingCaseError(backupTier);
}
await itemStorage.put('backupSubscriptionStatus', result);
await itemStorage.put('backupSubscriptionStatus', {
...result,
lastFetchedAtMs: Date.now(),
isFetching: false,
});
return result;
}

View File

@@ -10,6 +10,15 @@ export enum BackupLevel {
Paid = 201,
}
export function backupLevelFromNumber(
num: number | undefined
): BackupLevel | null {
if (Object.values(BackupLevel).includes(num as BackupLevel)) {
return num as BackupLevel;
}
return null;
}
export type AboutMe = {
aci: AciString;
pni?: PniString;

View File

@@ -58,7 +58,7 @@ import { PhoneNumberSharingMode } from '../../types/PhoneNumberSharingMode.js';
import { writeProfile } from '../../services/writeProfile.js';
import { getConversation } from '../../util/getConversation.js';
import { waitForEvent } from '../../shims/events.js';
import { MINUTE } from '../../util/durations/index.js';
import { DAY, MINUTE } from '../../util/durations/index.js';
import { sendSyncRequests } from '../../textsecure/syncRequests.js';
import { SmartUpdateDialog } from './UpdateDialog.js';
import { Preferences } from '../../components/Preferences.js';
@@ -110,6 +110,8 @@ import {
} from './PreferencesNotificationProfiles.js';
import type { ExternalProps as SmartNotificationProfilesProps } from './PreferencesNotificationProfiles.js';
import { getProfiles } from '../selectors/notificationProfiles.js';
import { backupLevelFromNumber } from '../../services/backups/types.js';
import { getMessageQueueTime } from '../../util/getMessageQueueTime.js';
const DEFAULT_NOTIFICATION_SETTING = 'message';
@@ -552,6 +554,7 @@ export function SmartPreferences(): JSX.Element | null {
const {
backupSubscriptionStatus,
backupTier,
cloudBackupStatus,
localBackupFolder,
backupMediaDownloadCompletedBytes,
@@ -576,6 +579,7 @@ export function SmartPreferences(): JSX.Element | null {
const backupFeatureEnabled = isBackupFeatureEnabled(items.remoteConfig);
const backupLocalBackupsEnabled = isLocalBackupsEnabled(items.remoteConfig);
const backupFreeMediaDays = getMessageQueueTime(items.remoteConfig) / DAY;
// Two-way items
@@ -770,9 +774,11 @@ export function SmartPreferences(): JSX.Element | null {
availableSpeakers={availableSpeakers}
backupFeatureEnabled={backupFeatureEnabled}
backupKeyViewed={backupKeyViewed}
backupTier={backupLevelFromNumber(backupTier)}
backupSubscriptionStatus={
backupSubscriptionStatus ?? { status: 'off' }
backupSubscriptionStatus ?? { status: 'not-found' }
}
backupFreeMediaDays={backupFreeMediaDays}
backupMediaDownloadStatus={{
completedBytes: backupMediaDownloadCompletedBytes ?? 0,
totalBytes: backupMediaDownloadTotalBytes ?? 0,

View File

@@ -232,7 +232,7 @@ export type StorageAccessType = {
backupTier: number | undefined;
cloudBackupStatus: BackupStatusType | undefined;
backupSubscriptionStatus: BackupsSubscriptionType;
backupSubscriptionStatus: BackupsSubscriptionType | undefined;
backupKeyViewed: boolean;
localBackupFolder: string | undefined;

View File

@@ -47,13 +47,9 @@ export type BackupMediaDownloadStatusType = {
isIdle: boolean;
};
export type BackupsSubscriptionType =
export type BackupsSubscriptionType = (
| {
status: 'off' | 'not-found' | 'expired';
}
| {
status: 'free';
mediaIncludedInBackupDurationDays: number;
status: 'not-found' | 'expired';
}
| (
| {
@@ -66,7 +62,8 @@ export type BackupsSubscriptionType =
expiryTimestamp?: number;
cost?: SubscriptionCostType;
}
);
)
) & { lastFetchedAtMs?: number; isFetching?: boolean };
export type LocalBackupMetadataVerificationType = {
snapshotDir: string;

View File

@@ -5,11 +5,13 @@ import * as RemoteConfig from '../RemoteConfig.js';
import { MONTH, SECOND } from './durations/index.js';
import { parseIntWithFallback } from './parseIntWithFallback.js';
export function getMessageQueueTime(): number {
export function getMessageQueueTime(
reduxConfig?: RemoteConfig.ConfigMapType
): number {
return (
Math.max(
parseIntWithFallback(
RemoteConfig.getValue('global.messageQueueTimeInSeconds'),
RemoteConfig.getValue('global.messageQueueTimeInSeconds', reduxConfig),
MONTH / SECOND
),
MONTH / SECOND