// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useState, useCallback, useRef } from 'react'; import classNames from 'classnames'; import { v4 as uuid } from 'uuid'; import type { RowType } from '@signalapp/sqlcipher'; import type { LocalizerType } from '../types/I18N.js'; import { toLogFormat } from '../types/errors.js'; import { formatFileSize } from '../util/formatFileSize.js'; import { SECOND } from '../util/durations/index.js'; import type { ValidationResultType as BackupValidationResultType } from '../services/backups/index.js'; import { SettingsRow, FlowingSettingsControl } from './PreferencesUtil.js'; import { Button, ButtonVariant } from './Button.js'; import { Spinner } from './Spinner.js'; import type { MessageCountBySchemaVersionType } from '../sql/Interface.js'; import type { MessageAttributesType } from '../model-types.js'; import type { DonationReceipt } from '../types/Donations.js'; import { createLogger } from '../logging/log.js'; import { isStagingServer } from '../util/isStagingServer.js'; import { getHumanDonationAmount } from '../util/currency.js'; import { AutoSizeTextArea } from './AutoSizeTextArea.js'; const log = createLogger('PreferencesInternal'); export function PreferencesInternal({ i18n, exportLocalBackup: doExportLocalBackup, validateBackup: doValidateBackup, getMessageCountBySchemaVersion, getMessageSampleForSchemaVersion, donationReceipts, internalAddDonationReceipt, saveAttachmentToDisk, generateDonationReceiptBlob, __dangerouslyRunAbitraryReadOnlySqlQuery, }: { i18n: LocalizerType; exportLocalBackup: () => Promise; validateBackup: () => Promise; getMessageCountBySchemaVersion: () => Promise; getMessageSampleForSchemaVersion: ( version: number ) => Promise>; donationReceipts: ReadonlyArray; internalAddDonationReceipt: (receipt: DonationReceipt) => void; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; baseDir?: string | undefined; }) => Promise<{ fullPath: string; name: string } | null>; generateDonationReceiptBlob: ( receipt: DonationReceipt, i18n: LocalizerType ) => Promise; __dangerouslyRunAbitraryReadOnlySqlQuery: ( readonlySqlQuery: string ) => Promise>>; }): JSX.Element { const [isExportPending, setIsExportPending] = useState(false); const [exportResult, setExportResult] = useState< BackupValidationResultType | undefined >(); const [messageCountBySchemaVersion, setMessageCountBySchemaVersion] = useState(); const [messageSampleForVersions, setMessageSampleForVersions] = useState<{ [schemaVersion: number]: Array; }>(); const [isValidationPending, setIsValidationPending] = useState(false); const [validationResult, setValidationResult] = useState< BackupValidationResultType | undefined >(); const [readOnlySqlInput, setReadOnlySqlInput] = useState(''); const [readOnlySqlResults, setReadOnlySqlResults] = useState > | null>(null); const validateBackup = useCallback(async () => { setIsValidationPending(true); setValidationResult(undefined); try { setValidationResult(await doValidateBackup()); } catch (error) { setValidationResult({ error: toLogFormat(error) }); } finally { setIsValidationPending(false); } }, [doValidateBackup]); const renderValidationResult = useCallback( ( backupResult: BackupValidationResultType | undefined ): JSX.Element | undefined => { if (backupResult == null) { return; } if ('result' in backupResult) { const { result: { totalBytes, stats, duration }, } = backupResult; let snapshotDirEl: JSX.Element | undefined; if ('snapshotDir' in backupResult.result) { snapshotDirEl = (

Backup path:

                {backupResult.result.snapshotDir}
              

); } return (
{snapshotDirEl}

Main file size: {formatFileSize(totalBytes)}

Duration: {Math.round(duration / SECOND)}s

              {JSON.stringify(stats, null, 2)}
            
); } const { error } = backupResult; return (
            {error}
          
); }, [] ); const exportLocalBackup = useCallback(async () => { setIsExportPending(true); setExportResult(undefined); try { setExportResult(await doExportLocalBackup()); } catch (error) { setExportResult({ error: toLogFormat(error) }); } finally { setIsExportPending(false); } }, [doExportLocalBackup]); // Donation receipt states const [isGeneratingReceipt, setIsGeneratingReceipt] = useState(false); const handleAddTestReceipt = useCallback(async () => { const testReceipt: DonationReceipt = { id: uuid(), currencyType: 'USD', paymentAmount: Math.floor(Math.random() * 10000) + 100, // Random amount between $1 and $100 (in cents) timestamp: Date.now(), }; try { await internalAddDonationReceipt(testReceipt); } catch (error) { log.error('Error adding test receipt:', toLogFormat(error)); } }, [internalAddDonationReceipt]); const handleGenerateReceipt = useCallback( async (receipt: DonationReceipt) => { setIsGeneratingReceipt(true); try { const blob = await generateDonationReceiptBlob(receipt, i18n); const buffer = await blob.arrayBuffer(); const result = await saveAttachmentToDisk({ name: `Signal_Receipt_${new Date(receipt.timestamp).toISOString().split('T')[0]}.png`, data: new Uint8Array(buffer), }); if (result) { log.info('Receipt saved to:', result.fullPath); } } catch (error) { log.error('Error generating receipt:', toLogFormat(error)); } finally { setIsGeneratingReceipt(false); } }, [i18n, saveAttachmentToDisk, generateDonationReceiptBlob] ); const handleReadonlySqlInputChange = useCallback( (newReadonlySqlInput: string) => { setReadOnlySqlInput(newReadonlySqlInput); }, [] ); const prevAbortControlerRef = useRef(null); const handleReadOnlySqlInputSubmit = useCallback(async () => { const controller = new AbortController(); const { signal } = controller; prevAbortControlerRef.current?.abort(); prevAbortControlerRef.current = controller; setReadOnlySqlResults(null); const result = await __dangerouslyRunAbitraryReadOnlySqlQuery(readOnlySqlInput); if (signal.aborted) { return; } setReadOnlySqlResults(result); }, [readOnlySqlInput, __dangerouslyRunAbitraryReadOnlySqlQuery]); return (
{i18n('icu:Preferences__internal__validate-backup--description')}
{renderValidationResult(validationResult)}
{i18n( 'icu:Preferences__internal__export-local-backup--description' )}
{renderValidationResult(exportResult)}
Check message schema versions
{messageCountBySchemaVersion ? (
              
                  {messageCountBySchemaVersion.map(
                    ({ schemaVersion, count }) => {
                      return (
                        
                          
                          {messageSampleForVersions?.[schemaVersion] ? (
                            
                          ) : null}
                        
                      );
                    }
                  )}
                
Schema version # Messages
{schemaVersion} {count}
{JSON.stringify( messageSampleForVersions[schemaVersion], null, 2 )}
) : null}
{isStagingServer() && (
Test donation receipt generation functionality
{donationReceipts.length > 0 ? (

Receipts ({donationReceipts.length})

{donationReceipts.map(receipt => ( ))}
Date Amount Last 4 ID Actions
{new Date(receipt.timestamp).toLocaleDateString()} {getHumanDonationAmount(receipt)} {receipt.currencyType} {receipt.id.substring(0, 8)}...
) : (

No receipts found. Add some test receipts above.

)}
)} {readOnlySqlResults != null && ( null} readOnly placeholder="" moduleClassName="Preferences__ReadonlySqlPlayground__Textarea" /> )}
); }