// 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 } from '../types/Util'; import { Page, PreferencesContent } from './Preferences'; import { PreferencesDonateFlow } from './PreferencesDonateFlow'; import type { DonationWorkflow, DonationReceipt, OneTimeDonationHumanAmounts, } from '../types/Donations'; import type { AvatarColorType } from '../types/Colors'; import type { AvatarDataType } from '../types/Avatar'; import { AvatarPreview } from './AvatarPreview'; 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 type { SubmitDonationType } from '../state/ducks/donations'; import { getHumanDonationAmount } from '../util/currency'; const log = createLogger('PreferencesDonations'); type PropsExternalType = { contentsRef: MutableRefObject; }; export type PropsDataType = { i18n: LocalizerType; isStaging: boolean; page: Page; workflow: DonationWorkflow | undefined; userAvatarData: ReadonlyArray; color?: AvatarColorType; firstName?: string; profileAvatarUrl?: string; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; validCurrencies: ReadonlyArray; 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; }; type PropsActionType = { clearWorkflow: () => void; setPage: (page: Page) => void; submitDonation: (payload: SubmitDonationType) => void; }; export type PropsType = PropsDataType & PropsActionType & PropsExternalType; type DonationPage = | Page.Donations | Page.DonationsDonateFlow | Page.DonationsReceiptList; type PreferencesHomeProps = PropsType & { navigateToPage: (newPage: Page) => void; }; function isDonationPage(page: Page): page is DonationPage { return ( page === Page.Donations || page === Page.DonationsDonateFlow || page === Page.DonationsReceiptList ); } function LearnMoreButton(parts: ReactNode): JSX.Element { return ( ); } function DonationsHome({ i18n, userAvatarData, color, firstName, profileAvatarUrl, navigateToPage, setPage, isStaging, donationReceipts, }: PreferencesHomeProps): JSX.Element { const avatarData = userAvatarData[0]; const avatarBuffer = avatarData?.buffer; const hasReceipts = donationReceipts.length > 0; return (
{i18n('icu:PreferencesDonations__title')}
{isStaging && ( )}
{hasReceipts && ( { navigateToPage(Page.DonationsReceiptList); }} > {i18n('icu:PreferencesDonations__receipts')} )} { // TODO: Handle donation FAQs action }} > {i18n('icu:PreferencesDonations__faqs')}
); } 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, userAvatarData, color, firstName, profileAvatarUrl, donationAmountsConfig, validCurrencies, donationReceipts, saveAttachmentToDisk, generateDonationReceiptBlob, showToast, }: PropsType): JSX.Element | null { const navigateToPage = useCallback( (newPage: Page) => { setPage(newPage); }, [setPage] ); if (!isDonationPage(page)) { return null; } let content; if (page === Page.DonationsDonateFlow) { // DonateFlow has to control Back button to switch between CC form and Amount picker return ( setPage(Page.Donations)} /> ); } if (page === Page.Donations) { content = ( ); } else if (page === Page.DonationsReceiptList) { content = ( ); } let title: string | undefined; let backButton: JSX.Element | undefined; if (page === Page.Donations) { title = i18n('icu:Preferences__DonateTitle'); } else if (page === Page.DonationsReceiptList) { title = i18n('icu:PreferencesDonations__receipts'); backButton = (