// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useEffect, useMemo, useState } from 'react'; import lodash from 'lodash'; import type { MutableRefObject, ReactNode } from 'react'; import { ListBox, ListBoxItem } from 'react-aria-components'; import { getDateTimeFormatter } from '../util/formatTimestamp.js'; import type { LocalizerType, ThemeType } from '../types/Util.js'; import { PreferencesContent } from './Preferences.js'; import { SettingsPage } from '../types/Nav.js'; import { PreferencesDonateFlow } from './PreferencesDonateFlow.js'; import type { DonationWorkflow, DonationReceipt, OneTimeDonationHumanAmounts, DonationErrorType, } from '../types/Donations.js'; import { donationErrorTypeSchema, donationStateSchema, } from '../types/Donations.js'; import type { AvatarColorType } from '../types/Colors.js'; import { Button, ButtonSize, ButtonVariant } from './Button.js'; import { Modal } from './Modal.js'; import { Spinner } from './Spinner.js'; import type { AnyToast } from '../types/Toast.js'; import { ToastType } from '../types/Toast.js'; import { createLogger } from '../logging/log.js'; import { toLogFormat } from '../types/errors.js'; import { I18n } from './I18n.js'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser.js'; import { DonationPrivacyInformationModal } from './DonationPrivacyInformationModal.js'; import type { SubmitDonationType } from '../state/ducks/donations.js'; import { getHumanDonationAmount, toHumanCurrencyString, } from '../util/currency.js'; import { Avatar, AvatarSize } from './Avatar.js'; import type { BadgeType } from '../badges/types.js'; import { DonationInterruptedModal } from './DonationInterruptedModal.js'; import { DonationErrorModal } from './DonationErrorModal.js'; import { DonationVerificationModal } from './DonationVerificationModal.js'; import { DonationProgressModal } from './DonationProgressModal.js'; import { DonationStillProcessingModal } from './DonationStillProcessingModal.js'; import { DonationThanksModal } from './DonationThanksModal.js'; import type { ConversationType, ProfileDataType, } from '../state/ducks/conversations.js'; import type { AvatarUpdateOptionsType } from '../types/Avatar.js'; import { drop } from '../util/drop.js'; import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip.js'; import { getInProgressDonation } from '../util/donations.js'; const { groupBy, sortBy } = lodash; const log = createLogger('PreferencesDonations'); type PropsExternalType = { contentsRef: MutableRefObject; }; export type PropsDataType = { i18n: LocalizerType; initialCurrency: string; isOnline: boolean; page: SettingsPage; didResumeWorkflowAtStartup: boolean; lastError: DonationErrorType | undefined; workflow: DonationWorkflow | undefined; badge: BadgeType | undefined; color: AvatarColorType | undefined; firstName: string | undefined; profileAvatarUrl?: string; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; validCurrencies: ReadonlyArray; donationReceipts: ReadonlyArray; theme: ThemeType; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; baseDir?: string | undefined; }) => Promise<{ fullPath: string; name: string } | null>; generateDonationReceiptBlob: ( receipt: DonationReceipt, i18n: LocalizerType ) => Promise; showToast: (toast: AnyToast) => void; donationBadge: BadgeType | undefined; fetchBadgeData: () => Promise; me: ConversationType; myProfileChanged: ( profileData: ProfileDataType, avatarUpdateOptions: AvatarUpdateOptionsType ) => void; }; type PropsActionType = { applyDonationBadge: (args: { badge: BadgeType | undefined; applyBadge: boolean; onComplete: (error?: Error) => void; }) => void; clearWorkflow: () => void; resumeWorkflow: () => void; setPage: (page: SettingsPage) => void; showToast: (toast: AnyToast) => void; submitDonation: (payload: SubmitDonationType) => void; updateLastError: (error: DonationErrorType | undefined) => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; type DonationPage = | SettingsPage.Donations | SettingsPage.DonationsDonateFlow | SettingsPage.DonationsReceiptList; type PreferencesHomeProps = Pick< PropsType, | 'contentsRef' | 'i18n' | 'setPage' | 'isOnline' | 'donationReceipts' | 'workflow' > & { navigateToPage: (newPage: SettingsPage) => void; renderDonationHero: () => JSX.Element; }; function isDonationPage(page: SettingsPage): page is DonationPage { return ( page === SettingsPage.Donations || page === SettingsPage.DonationsDonateFlow || page === SettingsPage.DonationsReceiptList ); } type DonationHeroProps = Pick< PropsDataType, 'badge' | 'color' | 'firstName' | 'i18n' | 'profileAvatarUrl' | 'theme' > & { showPrivacyModal: () => void; }; function DonationHero({ badge, color, firstName, i18n, profileAvatarUrl, theme, showPrivacyModal, }: DonationHeroProps): JSX.Element { const privacyReadMoreLink = useCallback( (parts: ReactNode): JSX.Element => { return ( ); }, [showPrivacyModal] ); return ( <>
{i18n('icu:PreferencesDonations__title')}
); } function DonationsHome({ i18n, renderDonationHero, navigateToPage, setPage, isOnline, donationReceipts, workflow, }: PreferencesHomeProps): JSX.Element { const [isInProgressModalVisible, setIsInProgressVisible] = useState(false); const inProgressDonationAmount = useMemo(() => { const inProgressDonation = getInProgressDonation(workflow); return inProgressDonation ? toHumanCurrencyString(inProgressDonation) : undefined; }, [workflow]); const handleDonateButtonClicked = useCallback(() => { if (inProgressDonationAmount) { setIsInProgressVisible(true); } else { setPage(SettingsPage.DonationsDonateFlow); } }, [inProgressDonationAmount, setPage]); const handleInProgressDonationClicked = useCallback(() => { setIsInProgressVisible(true); }, []); const hasReceipts = donationReceipts.length > 0; const donateButton = ( ); return (
{isInProgressModalVisible && ( setIsInProgressVisible(false)} /> )} {renderDonationHero()} {isOnline ? ( donateButton ) : ( {donateButton} )}
{(hasReceipts || inProgressDonationAmount) && (
{i18n('icu:PreferencesDonations__my-support')}
)} {inProgressDonationAmount && (
{i18n('icu:PreferencesDonations__badge-label-one-time', { formattedCurrencyAmount: inProgressDonationAmount, })}
{i18n('icu:PreferencesDonations__badge-processing-donation')}
)} {hasReceipts && ( { navigateToPage(SettingsPage.DonationsReceiptList); }} > {i18n('icu:PreferencesDonations__receipts')} )} { openLinkInWebBrowser( 'https://support.signal.org/hc/articles/360031949872-Donor-FAQs' ); }} > {i18n('icu:PreferencesDonations__faqs')}
{i18n('icu:PreferencesDonations__mobile-info')}
); } function PreferencesReceiptList({ i18n, donationReceipts, saveAttachmentToDisk, generateDonationReceiptBlob, showToast, }: { i18n: LocalizerType; donationReceipts: ReadonlyArray; saveAttachmentToDisk: (options: { data: Uint8Array; name: string; baseDir?: string | undefined; }) => Promise<{ fullPath: string; name: string } | null>; generateDonationReceiptBlob: ( receipt: DonationReceipt, i18n: LocalizerType ) => Promise; showToast: (toast: AnyToast) => void; }): JSX.Element { const [selectedReceipt, setSelectedReceipt] = useState(null); const [isDownloading, setIsDownloading] = useState(false); const hasReceipts = useMemo( () => donationReceipts.length > 0, [donationReceipts] ); const receiptsByYear = useMemo(() => { const sortedReceipts = sortBy( donationReceipts, receipt => -receipt.timestamp ); return groupBy(sortedReceipts, receipt => new Date(receipt.timestamp).getFullYear() ); }, [donationReceipts]); const handleDownloadReceipt = useCallback(async () => { if (!selectedReceipt) { return; } setIsDownloading(true); try { const blob = await generateDonationReceiptBlob(selectedReceipt, i18n); const buffer = await blob.arrayBuffer(); const result = await saveAttachmentToDisk({ name: `Signal_Receipt_${new Date(selectedReceipt.timestamp).toISOString().split('T')[0]}.png`, data: new Uint8Array(buffer), }); if (result) { setSelectedReceipt(null); showToast({ toastType: ToastType.ReceiptSaved, parameters: { fullPath: result.fullPath }, }); } } catch (error) { log.error('Failed to generate receipt: ', toLogFormat(error)); showToast({ toastType: ToastType.ReceiptSaveFailed, }); } finally { setIsDownloading(false); } }, [ selectedReceipt, generateDonationReceiptBlob, i18n, saveAttachmentToDisk, showToast, ]); const dateFormatter = getDateTimeFormatter({ month: 'short', day: 'numeric', year: 'numeric', }); return (
{hasReceipts ? ( <>
{i18n('icu:PreferencesDonations--receiptList__info')}
{Object.entries(receiptsByYear).map(([year, receipts]) => (
{year}
{receipts.map(receipt => ( ))}
))} ) : (
{i18n('icu:PreferencesDonations--receiptList__empty-title')}
{i18n('icu:PreferencesDonations--receiptList__info')}
)} {selectedReceipt && ( setSelectedReceipt(null)} modalFooter={ } >
{toHumanCurrencyString({ amount: getHumanDonationAmount(selectedReceipt), currency: selectedReceipt.currencyType, })}

{i18n('icu:PreferencesDonations__ReceiptModal--type-label')}
{i18n('icu:DonationReceipt__type-value--one-time')}
{i18n( 'icu:PreferencesDonations__ReceiptModal--date-paid-label' )}
{dateFormatter.format(new Date(selectedReceipt.timestamp))}
)}
); } export function PreferencesDonations({ contentsRef, i18n, initialCurrency, isOnline, page, workflow, didResumeWorkflowAtStartup, lastError, applyDonationBadge, clearWorkflow, resumeWorkflow, setPage, submitDonation, badge, color, firstName, profileAvatarUrl, donationAmountsConfig, validCurrencies, donationReceipts, theme, saveAttachmentToDisk, generateDonationReceiptBlob, showToast, updateLastError, donationBadge, fetchBadgeData, }: PropsType): JSX.Element | null { const [hasProcessingExpired, setHasProcessingExpired] = useState(false); const [isSubmitted, setIsSubmitted] = useState(false); const [isPrivacyModalVisible, setIsPrivacyModalVisible] = useState(false); // Fetch badge data when we're about to show the badge modal useEffect(() => { if ( workflow?.type === donationStateSchema.Enum.DONE && page === SettingsPage.Donations && !donationBadge ) { drop(fetchBadgeData()); } }, [workflow, page, donationBadge, fetchBadgeData]); const navigateToPage = useCallback( (newPage: SettingsPage) => { setPage(newPage); }, [setPage] ); useEffect(() => { if (lastError) { setIsSubmitted(false); } if ( workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT || workflow?.type === donationStateSchema.Enum.DONE ) { setIsSubmitted(false); } }, [lastError, workflow, setIsSubmitted]); const renderDonationHero = useCallback( () => ( setIsPrivacyModalVisible(true)} /> ), [badge, color, firstName, i18n, profileAvatarUrl, theme] ); if (!isDonationPage(page)) { return null; } let dialog: ReactNode | undefined; if (lastError) { dialog = ( { setIsSubmitted(false); if ( workflow?.type === 'DONE' && lastError === donationErrorTypeSchema.Enum.BadgeApplicationFailed ) { clearWorkflow(); } updateLastError(undefined); }} /> ); } else if ( didResumeWorkflowAtStartup && workflow?.type === donationStateSchema.Enum.INTENT_METHOD ) { dialog = ( { clearWorkflow(); setPage(SettingsPage.Donations); showToast({ toastType: ToastType.DonationCanceled }); }} onRetryDonation={() => { resumeWorkflow(); }} /> ); } else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) { dialog = ( { clearWorkflow(); setPage(SettingsPage.Donations); showToast({ toastType: ToastType.DonationCanceled }); }} onOpenBrowser={() => { openLinkInWebBrowser(workflow.redirectTarget); }} onTimedOut={() => { clearWorkflow(); updateLastError(donationErrorTypeSchema.Enum.TimedOut); setPage(SettingsPage.Donations); }} /> ); } else if (workflow?.type === donationStateSchema.Enum.DONE) { dialog = ( { if (error) { log.error('Badge application failed:', error.message); updateLastError( donationErrorTypeSchema.Enum.BadgeApplicationFailed ); } else { clearWorkflow(); } }} /> ); } else if ( page === SettingsPage.DonationsDonateFlow && (isSubmitted || workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT) ) { // We can't transition away from the payment screen until that payment information // has been accepted. Even if it takes more than 30 seconds. if ( hasProcessingExpired && (workflow?.type === donationStateSchema.Enum.INTENT_CONFIRMED || workflow?.type === donationStateSchema.Enum.RECEIPT) ) { dialog = ( { setPage(SettingsPage.Donations); // We need to delay until we've transitioned away from this page, or we'll // go back to showing the spinner. setTimeout(() => setHasProcessingExpired(false), 500); }} /> ); } else { dialog = ( setHasProcessingExpired(true)} /> ); } } const privacyModal = isPrivacyModalVisible ? ( setIsPrivacyModalVisible(false)} /> ) : null; let content; if (page === SettingsPage.DonationsDonateFlow) { // DonateFlow has to control Back button to switch between CC form and Amount picker return ( <> {dialog} {privacyModal} { setIsSubmitted(true); submitDonation(details); }} showPrivacyModal={() => setIsPrivacyModalVisible(true)} onBack={() => setPage(SettingsPage.Donations)} /> ); } if (page === SettingsPage.Donations) { content = ( ); } else if (page === SettingsPage.DonationsReceiptList) { content = ( ); } let title: string | undefined; let backButton: JSX.Element | undefined; if (page === SettingsPage.Donations) { title = i18n('icu:Preferences__DonateTitle'); } else if (page === SettingsPage.DonationsReceiptList) { title = i18n('icu:PreferencesDonations__receipts'); backButton = (