Show backup status in Settings window

This commit is contained in:
trevor-signal
2025-04-02 14:57:29 -04:00
committed by GitHub
parent 51647fef95
commit aba0e028d4
25 changed files with 1136 additions and 191 deletions

View File

@@ -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',
},
};

View File

@@ -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<T = string | number> = (value: T) => unknown;
@@ -77,7 +82,10 @@ type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
export type PropsDataType = {
// Settings
autoDownloadAttachment: AutoDownloadAttachmentType;
backupFeatureEnabled: boolean;
blockedCount: number;
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
customColors: Record<string, CustomColorType>;
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<Array<ConversationType>>;
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<PropsType, 'i18n'>;
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>(Page.General);
const [page, setPage] = useState<Page>(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 = (
<PreferencesBackups
i18n={i18n}
cloudBackupStatus={cloudBackupStatus}
backupSubscriptionStatus={backupSubscriptionStatus}
locale={resolvedLocale}
/>
);
}
return (
@@ -1775,6 +1815,19 @@ export function Preferences({
>
{i18n('icu:Preferences__button--data-usage')}
</button>
{shouldShowBackupsPage ? (
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--backups': true,
'Preferences__button--selected': page === Page.Backups,
})}
onClick={() => setPage(Page.Backups)}
>
{i18n('icu:Preferences__button--backups')}
</button>
) : null}
</div>
<div className="Preferences__settings-pane" ref={settingsPaneRef}>
{settings}
@@ -1796,113 +1849,6 @@ export function Preferences({
);
}
function SettingsRow({
children,
title,
className,
}: {
children: ReactNode;
title?: string;
className?: string;
}): JSX.Element {
return (
<fieldset className={classNames('Preferences__settings-row', className)}>
{title && <legend className="Preferences__padding">{title}</legend>}
{children}
</fieldset>
);
}
function Control({
icon,
left,
onClick,
right,
}: {
/** A className or `true` to leave room for icon */
icon?: string | true;
left: ReactNode;
onClick?: () => unknown;
right: ReactNode;
}): JSX.Element {
const content = (
<>
{icon && (
<div
className={classNames(
'Preferences__control--icon',
icon === true ? null : icon
)}
/>
)}
<div className="Preferences__control--key">{left}</div>
<div className="Preferences__control--value">{right}</div>
</>
);
if (onClick) {
return (
<button
className="Preferences__control Preferences__control--clickable"
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return <div className="Preferences__control">{content}</div>;
}
type SettingsRadioOptionType<Enum> = Readonly<{
text: string;
value: Enum;
readOnly?: boolean;
onClick?: () => void;
}>;
function SettingsRadio<Enum>({
value,
options,
onChange,
}: {
value: Enum;
options: ReadonlyArray<SettingsRadioOptionType<Enum>>;
onChange: (value: Enum) => void;
}): JSX.Element {
const htmlIds = useMemo(() => {
return Array.from({ length: options.length }, () => uuid());
}, [options.length]);
return (
<div className="Preferences__padding">
{options.map(({ text, value: optionValue, readOnly, onClick }, i) => {
const htmlId = htmlIds[i];
return (
<label
className={classNames('Preferences__settings-radio__label', {
'Preferences__settings-radio__label--readonly': readOnly,
})}
key={htmlId}
htmlFor={htmlId}
>
<CircleCheckbox
isRadio
variant={CircleCheckboxVariant.Small}
id={htmlId}
checked={value === optionValue}
onClick={onClick}
onChange={readOnly ? noop : () => onChange(optionValue)}
/>
{text}
</label>
);
})}
</div>
);
}
function localizeDefault(i18n: LocalizerType, deviceLabel: string): string {
return deviceLabel.toLowerCase().startsWith('default')
? deviceLabel.replace(

View File

@@ -0,0 +1,220 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type {
BackupsSubscriptionType,
BackupStatusType,
} from '../types/backups';
import type { LocalizerType } from '../types/I18N';
import { formatTimestamp } from '../util/formatTimestamp';
import { formatFileSize } from '../util/formatFileSize';
import { SettingsRow } from './PreferencesUtil';
import { missingCaseError } from '../util/missingCaseError';
export function PreferencesBackups({
cloudBackupStatus,
backupSubscriptionStatus,
i18n,
locale,
}: {
cloudBackupStatus?: BackupStatusType;
backupSubscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
locale: string;
}): JSX.Element {
return (
<>
<div className="Preferences__title Preferences__title--backups">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--backups')}
</div>
</div>
<div className="Preferences--backups-summary__container">
{getBackupsSubscriptionSummary({
subscriptionStatus: backupSubscriptionStatus,
i18n,
locale,
})}
</div>
{cloudBackupStatus ? (
<SettingsRow
className="Preferences--backup-details"
title={i18n('icu:Preferences--backup-details__header')}
>
{cloudBackupStatus.createdAt ? (
<div className="Preferences--backup-details__row">
<label>{i18n('icu:Preferences--backup-created-at__label')}</label>
<div
id="Preferences--backup-details__value"
className="Preferences--backup-details__value"
>
{/* TODO (DESKTOP-8509) */}
{i18n('icu:Preferences--backup-created-by-phone')}
<span className="Preferences--backup-details__value-divider" />
{formatTimestamp(cloudBackupStatus.createdAt, {
dateStyle: 'medium',
timeStyle: 'short',
})}
</div>
</div>
) : null}
{cloudBackupStatus.mediaSize != null ||
cloudBackupStatus.protoSize != null}
<div className="Preferences--backup-details__row">
<label>
{i18n('icu:Preferences--backup-size__label')}{' '}
<div className="Preferences--backup-details__value">
{formatFileSize(
(cloudBackupStatus.mediaSize ?? 0) +
(cloudBackupStatus.protoSize ?? 0)
)}
</div>
</label>
</div>
</SettingsRow>
) : null}
</>
);
}
function getSubscriptionDetails({
i18n,
subscriptionStatus,
locale,
}: {
i18n: LocalizerType;
locale: string;
subscriptionStatus: BackupsSubscriptionType;
}): JSX.Element | null {
if (subscriptionStatus.status === 'active') {
return (
<>
{subscriptionStatus.cost ? (
<div className="Preferences--backups-summary__subscription-price">
{new Intl.NumberFormat(locale, {
style: 'currency',
currency: subscriptionStatus.cost.currencyCode,
currencyDisplay: 'narrowSymbol',
}).format(subscriptionStatus.cost.amount)}{' '}
/ month
</div>
) : null}
{subscriptionStatus.renewalDate ? (
<div className="Preferences--backups-summary__renewal-date">
{i18n('icu:Preferences--backup-plan__renewal-date', {
date: formatTimestamp(subscriptionStatus.renewalDate.getTime(), {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
if (subscriptionStatus.status === 'pending-cancellation') {
return (
<>
<div className="Preferences--backups-summary__canceled">
{i18n('icu:Preferences--backup-plan__canceled')}
</div>
{subscriptionStatus.expiryDate ? (
<div className="Preferences--backups-summary__expiry-date">
{i18n('icu:Preferences--backup-plan__expiry-date', {
date: formatTimestamp(subscriptionStatus.expiryDate.getTime(), {
dateStyle: 'medium',
}),
})}
</div>
) : null}
</>
);
}
return null;
}
export function getBackupsSubscriptionSummary({
subscriptionStatus,
i18n,
locale,
}: {
locale: string;
subscriptionStatus?: BackupsSubscriptionType;
i18n: LocalizerType;
}): JSX.Element | null {
if (!subscriptionStatus) {
return null;
}
const { status } = subscriptionStatus;
switch (status) {
case 'active':
case 'pending-cancellation':
return (
<>
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-media-plan__description')}
</div>
<div className="Preferences--backups-summary__content">
{getSubscriptionDetails({ i18n, locale, subscriptionStatus })}
</div>
</div>
{subscriptionStatus.status === 'active' ? (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--active" />
) : (
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--inactive" />
)}
</div>
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-media-plan__note')}
</div>
</>
);
case 'free':
return (
<>
<div className="Preferences--backups-summary__status-container">
<div>
<div className="Preferences--backups-summary__type">
{i18n('icu:Preferences--backup-messages-plan__description', {
mediaDayCount:
subscriptionStatus.mediaIncludedInBackupDurationDays,
})}
</div>
<div className="Preferences--backups-summary__content">
{i18n(
'icu:Preferences--backup-messages-plan__cost-description'
)}
</div>
</div>
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--active" />
</div>
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-messages-plan__note')}
</div>
</>
);
case 'not-found':
case 'expired':
return (
<>
<div className="Preferences--backups-summary__status-container ">
<div className="Preferences--backups-summary__content">
{i18n('icu:Preferences--backup-plan-not-found__description')}
</div>
<div className="Preferences--backups-summary__icon Preferences--backups-summary__icon--inactive" />
</div>
<div className="Preferences--backups-summary__note">
<div className="Preferences--backups-summary__note">
{i18n('icu:Preferences--backup-plan__not-found__note')}
</div>
</div>
</>
);
default:
throw missingCaseError(status);
}
}

View File

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