mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-24 20:26:24 +00:00
Show backup status in Settings window
This commit is contained in:
@@ -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',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
220
ts/components/PreferencesBackups.tsx
Normal file
220
ts/components/PreferencesBackups.tsx
Normal 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);
|
||||
}
|
||||
}
|
||||
118
ts/components/PreferencesUtil.tsx
Normal file
118
ts/components/PreferencesUtil.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user