// Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import React, { useCallback, useState } from 'react'; import { groupBy, sortBy } from 'lodash'; import type { MutableRefObject, ReactNode } from 'react'; import { ListBox, ListBoxItem } from 'react-aria-components'; import { getDateTimeFormatter } from '../util/formatTimestamp'; import type { LocalizerType, ThemeType } from '../types/Util'; import { PreferencesContent } from './Preferences'; import { SettingsPage } from '../types/Nav'; import { PreferencesDonateFlow } from './PreferencesDonateFlow'; import type { DonationWorkflow, DonationReceipt, OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { AvatarColorType } from '../types/Colors'; import { Button, ButtonSize, ButtonVariant } from './Button'; import { Modal } from './Modal'; import { Spinner } from './Spinner'; import type { AnyToast } from '../types/Toast'; import { ToastType } from '../types/Toast'; import { createLogger } from '../logging/log'; import { toLogFormat } from '../types/errors'; import { I18n } from './I18n'; import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; import { DonationPrivacyInformationModal } from './DonationPrivacyInformationModal'; import type { SubmitDonationType } from '../state/ducks/donations'; import { getHumanDonationAmount } from '../util/currency'; import { Avatar, AvatarSize } from './Avatar'; import type { BadgeType } from '../badges/types'; const log = createLogger('PreferencesDonations'); type PropsExternalType = { contentsRef: MutableRefObject; }; export type PropsDataType = { i18n: LocalizerType; isStaging: boolean; page: SettingsPage; 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; }; type PropsActionType = { clearWorkflow: () => void; setPage: (page: SettingsPage) => void; submitDonation: (payload: SubmitDonationType) => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; type DonationPage = | SettingsPage.Donations | SettingsPage.DonationsDonateFlow | SettingsPage.DonationsReceiptList; type PreferencesHomeProps = Omit & { 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' >; function DonationHero({ badge, color, firstName, i18n, profileAvatarUrl, theme, }: DonationHeroProps): JSX.Element { const [showPrivacyModal, setShowPrivacyModal] = useState(false); const ReadMoreButtonWithModal = useCallback( (parts: ReactNode): JSX.Element => { return ( ); }, [] ); return ( <> {showPrivacyModal && ( setShowPrivacyModal(false)} /> )}
{i18n('icu:PreferencesDonations__title')}
); } function DonationsHome({ i18n, renderDonationHero, navigateToPage, setPage, isStaging, donationReceipts, }: PreferencesHomeProps): JSX.Element { const hasReceipts = donationReceipts.length > 0; return (
{renderDonationHero()} {isStaging && ( )}
{hasReceipts && (
{i18n('icu:PreferencesDonations__my-support')}
)} {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 [showReceiptModal, setShowReceiptModal] = useState(false); const [selectedReceipt, setSelectedReceipt] = useState(null); const [isDownloading, setIsDownloading] = useState(false); const sortedReceipts = sortBy( donationReceipts, receipt => -receipt.timestamp ); const receiptsByYear = groupBy(sortedReceipts, receipt => new Date(receipt.timestamp).getFullYear() ); const dateFormatter = getDateTimeFormatter({ month: 'short', day: 'numeric', year: 'numeric', }); const preferredSystemLocales = window.SignalContext.getPreferredSystemLocales(); const localeOverride = window.SignalContext.getLocaleOverride(); const locales = localeOverride != null ? [localeOverride] : preferredSystemLocales; const getCurrencyFormatter = (currencyType: string) => new Intl.NumberFormat(locales, { style: 'currency', currency: currencyType, }); const hasReceipts = Object.keys(receiptsByYear).length > 0; 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) { setShowReceiptModal(false); 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, ]); return (
{hasReceipts ? ( <>
{i18n('icu:PreferencesDonations--receiptList__info')}
{Object.entries(receiptsByYear).map(([year, receipts]) => (
{year}
{receipts.map(receipt => ( { setSelectedReceipt(receipt); setShowReceiptModal(true); }} >
{dateFormatter.format(new Date(receipt.timestamp))}
{i18n('icu:DonationReceipt__type-value--one-time')}
{getCurrencyFormatter(receipt.currencyType).format( getHumanDonationAmount(receipt) )}
))}
))} ) : (
{i18n('icu:PreferencesDonations--receiptList__empty-title')}
{i18n('icu:PreferencesDonations--receiptList__info')}
)} {showReceiptModal && selectedReceipt && ( setShowReceiptModal(false)} modalFooter={ } >
{getCurrencyFormatter(selectedReceipt.currencyType).format( getHumanDonationAmount(selectedReceipt) )}

{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, isStaging, page, workflow, clearWorkflow, setPage, submitDonation, badge, color, firstName, profileAvatarUrl, donationAmountsConfig, validCurrencies, donationReceipts, theme, saveAttachmentToDisk, generateDonationReceiptBlob, showToast, }: PropsType): JSX.Element | null { const navigateToPage = useCallback( (newPage: SettingsPage) => { setPage(newPage); }, [setPage] ); const renderDonationHero = useCallback( () => ( ), [badge, color, firstName, i18n, profileAvatarUrl, theme] ); if (!isDonationPage(page)) { return null; } let content; if (page === SettingsPage.DonationsDonateFlow) { // DonateFlow has to control Back button to switch between CC form and Amount picker return ( 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 = (