// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ChangeEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState, useRef, } from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import type { LocalizerType } from '../types/I18N'; import { FlowingSettingsControl as FlowingControl, SettingsRow, } from './PreferencesUtil'; import { Button, ButtonSize, ButtonVariant } from './Button'; import { getOSAuthErrorString, SIGNAL_BACKUPS_LEARN_MORE_URL, } from './PreferencesBackups'; import { I18n } from './I18n'; import type { PreferencesBackupPage } from '../types/PreferencesBackupPage'; import { SettingsPage } from '../types/Nav'; import { ToastType } from '../types/Toast'; import type { ShowToastAction } from '../state/ducks/toast'; import { Modal } from './Modal'; import { strictAssert } from '../util/assert'; import type { PromptOSAuthReasonType, PromptOSAuthResultType, } from '../util/os/promptOSAuthMain'; import { ConfirmationDialog } from './ConfirmationDialog'; export function PreferencesLocalBackups({ accountEntropyPool, backupKeyViewed, i18n, localBackupFolder, onBackupKeyViewedChange, page, pickLocalBackupFolder, promptOSAuth, setPage, showToast, }: { accountEntropyPool: string | undefined; backupKeyViewed: boolean; i18n: LocalizerType; localBackupFolder: string | undefined; onBackupKeyViewedChange: (keyViewed: boolean) => void; page: PreferencesBackupPage; pickLocalBackupFolder: () => Promise; promptOSAuth: ( reason: PromptOSAuthReasonType ) => Promise; setPage: (page: PreferencesBackupPage) => void; showToast: ShowToastAction; }): JSX.Element { const [authError, setAuthError] = React.useState>(); const [isAuthPending, setIsAuthPending] = useState(false); if (!localBackupFolder) { return ( ); } const isReferencingBackupKey = page === SettingsPage.LocalBackupsKeyReference; if (!backupKeyViewed || isReferencingBackupKey) { strictAssert(accountEntropyPool, 'AEP is required for backup key viewer'); return ( { if (backupKeyViewed) { setPage(SettingsPage.LocalBackups); } else { onBackupKeyViewedChange(true); } }} showToast={showToast} /> ); } const learnMoreLink = (parts: Array) => ( {parts} ); return ( <>
{i18n('icu:Preferences__local-backups-section__description')}
{authError && ( setAuthError(undefined)} cancelButtonVariant={ButtonVariant.Secondary} cancelText={i18n('icu:ok')} > {getOSAuthErrorString(authError) ?? i18n('icu:error')} )} ); } function LocalBackupsSetupFolderPicker({ i18n, pickLocalBackupFolder, }: { i18n: LocalizerType; pickLocalBackupFolder: () => Promise; }): JSX.Element { return (
{i18n('icu:Preferences--local-backups-setup-folder')}
{i18n('icu:Preferences--local-backups-setup-folder-description')}
); } type BackupKeyStep = 'view' | 'confirm' | 'caution' | 'reference'; function LocalBackupsBackupKeyViewer({ accountEntropyPool, i18n, isReferencing, onBackupKeyViewed, showToast, }: { accountEntropyPool: string; i18n: LocalizerType; isReferencing: boolean; onBackupKeyViewed: () => void; showToast: ShowToastAction; }): JSX.Element { const [isBackupKeyConfirmed, setIsBackupKeyConfirmed] = useState(false); const [step, setStep] = useState( isReferencing ? 'reference' : 'view' ); const isStepViewOrReference = step === 'view' || step === 'reference'; const backupKey = useMemo(() => { return accountEntropyPool .replace(/\s/g, '') .replace(/.{4}(?=.)/g, '$& ') .toUpperCase(); }, [accountEntropyPool]); const onCopyBackupKey = useCallback( async function handleCopyBackupKey(e: React.MouseEvent) { e.preventDefault(); await window.navigator.clipboard.writeText(backupKey); showToast({ toastType: ToastType.CopiedBackupKey }); }, [backupKey, showToast] ); const learnMoreLink = (parts: Array) => ( {parts} ); let title: string; let description: JSX.Element | string; let footerLeft: JSX.Element | undefined; let footerRight: JSX.Element; if (isStepViewOrReference) { title = i18n('icu:Preferences--local-backups-record-backup-key'); description = ( ); if (step === 'view') { footerRight = ( ); } else { footerRight = ( ); } } else { title = i18n('icu:Preferences--local-backups-confirm-backup-key'); description = i18n( 'icu:Preferences--local-backups-confirm-backup-key-description' ); footerLeft = ( ); footerRight = ( ); } return (
{step === 'caution' && ( {i18n( 'icu:Preferences__local-backups-confirm-key-modal-continue' )} } onClose={() => setStep('confirm')} padded={false} >
{i18n('icu:Preferences__local-backups-confirm-key-modal-title')}
{i18n('icu:Preferences__local-backups-confirm-key-modal-body')}
)}
{title}
{description}
setIsBackupKeyConfirmed(isValid)} isStepViewOrReference={isStepViewOrReference} />
{isStepViewOrReference && (
)}
{footerLeft}
{footerRight}
); } function LocalBackupsBackupKeyTextarea({ backupKey, i18n, onValidate, isStepViewOrReference, }: { backupKey: string; i18n: LocalizerType; onValidate: (isValid: boolean) => void; isStepViewOrReference: boolean; }): JSX.Element { const backupKeyTextareaRef = useRef(null); const [backupKeyInput, setBackupKeyInput] = useState(''); useEffect(() => { if (backupKeyTextareaRef.current) { backupKeyTextareaRef.current.focus(); } }, [backupKeyTextareaRef, isStepViewOrReference]); const backupKeyNoSpaces = React.useMemo(() => { return backupKey.replace(/\s/g, ''); }, [backupKey]); const handleTextareaChange = useCallback( (ev: ChangeEvent) => { const { value } = ev.target; const valueUppercaseNoSpaces = value.replace(/\s/g, '').toUpperCase(); const valueForUI = valueUppercaseNoSpaces.replace(/.{4}(?=.)/g, '$& '); setBackupKeyInput(valueForUI); onValidate(valueUppercaseNoSpaces === backupKeyNoSpaces); }, [backupKeyNoSpaces, onValidate] ); return (