Donations: Show progress, error and verify dialogs

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
automated-signal 2025-07-22 10:12:01 -05:00 committed by GitHub
parent 9c1b6ff01a
commit 47b4cb98a9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 400 additions and 103 deletions

View file

@ -8902,14 +8902,30 @@
"messageformat": "Thank you for supporting Signal. Your contribution helps fuel the mission of protecting free expression and enabling secure global communication for millions around the world, through open source privacy technology. If youre a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.", "messageformat": "Thank you for supporting Signal. Your contribution helps fuel the mission of protecting free expression and enabling secure global communication for millions around the world, through open source privacy technology. If youre a resident of the United States, please retain this receipt for your tax records. Signal Technology Foundation is a tax-exempt nonprofit organization in the United States under section 501c3 of the Internal Revenue Code. Our Federal Tax ID is 82-4506840.",
"description": "Footer text shown on donation receipts explaining tax deductibility and Signal's mission" "description": "Footer text shown on donation receipts explaining tax deductibility and Signal's mission"
}, },
"icu:Donations__Toast__Cancelled": {
"messageformat": "Donation cancelled",
"description": "Toast shown when a donation was manually cancelled by the user"
},
"icu:Donations__Toast__Completed": { "icu:Donations__Toast__Completed": {
"messageformat": "Donation completed", "messageformat": "Donation completed",
"description": "Toast shown when a donation started processing after resuming on startup, and it completed successfully when the user is not on the Preferences/Donations screen" "description": "Toast shown when a donation started processing after resuming on startup, and it completed successfully when the user is not on the Preferences/Donations screen"
}, },
"icu:Donations__Toast__Error": {
"messageformat": "Error processing donation",
"description": "Toast shown when a donation fails when the user is not on the Preferences/Donations screen"
},
"icu:Donations__Toast__Processing": { "icu:Donations__Toast__Processing": {
"messageformat": "Processing donation", "messageformat": "Processing donation",
"description": "Toast shown when a donation starts processing again after resuming on startup" "description": "Toast shown when a donation starts processing again after resuming on startup"
}, },
"icu:Donations__Toast__VerificationFailed": {
"messageformat": "Verification failed",
"description": "Shown when the user is not on the Preferences/Donations screen, and for some reason their attempt to complete verification failed"
},
"icu:Donations__Toast__VerificationNeeded": {
"messageformat": "You have a donation in progress that needs additional verification.",
"description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup."
},
"icu:Donations__PaymentMethodDeclined": { "icu:Donations__PaymentMethodDeclined": {
"messageformat": "Payment method declined", "messageformat": "Payment method declined",
"description": "Title of the dialog shown with the user's provided payment method has not worked" "description": "Title of the dialog shown with the user's provided payment method has not worked"
@ -8918,14 +8934,6 @@
"messageformat": "Try another payment method or contact your bank for more information.", "messageformat": "Try another payment method or contact your bank for more information.",
"description": "An explanation for the 'payment declined' dialog" "description": "An explanation for the 'payment declined' dialog"
}, },
"icu:Donations__ErrorProcessingDonation": {
"messageformat": "Error processing donation",
"description": "Title of the dialog shown when a user's donation didn't seem to complete"
},
"icu:Donations__ErrorProcessingDonation__Description": {
"messageformat": "Try another payment method or contact your bank for more information.",
"description": "An explanation for the 'error processing' dialog"
},
"icu:Donations__Failed3dsValidation": { "icu:Donations__Failed3dsValidation": {
"messageformat": "Verification Failed", "messageformat": "Verification Failed",
"description": "Title of the dialog shown when something went wrong processing a user's 3ds verification with their bank" "description": "Title of the dialog shown when something went wrong processing a user's 3ds verification with their bank"
@ -8958,6 +8966,10 @@
"messageformat": "Additional verification required", "messageformat": "Additional verification required",
"description": "Title of the dialog shown when the user's payment method requires a redirect to a verification website" "description": "Title of the dialog shown when the user's payment method requires a redirect to a verification website"
}, },
"icu:Donations__3dsValidationNeeded--waiting": {
"messageformat": "Waiting for verification",
"description": "Title of the dialog shown when the user's payment method requires a redirect to a verification website"
},
"icu:Donations__3dsValidationNeeded__Description": { "icu:Donations__3dsValidationNeeded__Description": {
"messageformat": "Your credit card issuer requires an additional verification step in a web browser.", "messageformat": "Your credit card issuer requires an additional verification step in a web browser.",
"description": "An explanation for the 'verification required' dialog" "description": "An explanation for the 'verification required' dialog"
@ -8966,6 +8978,14 @@
"messageformat": "Open browser", "messageformat": "Open browser",
"description": "When external payment method validation is required, this button will open that external verification website" "description": "When external payment method validation is required, this button will open that external verification website"
}, },
"icu:Donations__3dsValidationNeeded__OpenBrowser--opened": {
"messageformat": "Re-open browser",
"description": "When the user has started external payment method validation, the button switches to this text. It allows them to kick off the process again if necessary."
},
"icu:Donations__3dsValidationNeeded__CancelDonation": {
"messageformat": "Cancel donation",
"description": "When external payment method validation is required, this button will open that external verification website"
},
"icu:WhatsNew__bugfixes": { "icu:WhatsNew__bugfixes": {
"messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.", "messageformat": "This version contains a number of small tweaks and bug fixes to keep Signal running smoothly.",
"description": "Release notes for releases that only include bug fixes", "description": "Release notes for releases that only include bug fixes",

View file

@ -11,3 +11,7 @@
.module-Modal__title.DonationVerificationModal__title { .module-Modal__title.DonationVerificationModal__title {
@include mixins.font-title-medium; @include mixins.font-title-medium;
} }
.DonationVerificationModal__body_inner {
@include mixins.font-body-2;
}

View file

@ -21,15 +21,6 @@ const defaultProps = {
onClose: action('onClose'), onClose: action('onClose'),
}; };
export function DonationProcessingError(): JSX.Element {
return (
<DonationErrorModal
{...defaultProps}
errorType={donationErrorTypeSchema.Enum.DonationProcessingError}
/>
);
}
export function Failed3dsValidation(): JSX.Element { export function Failed3dsValidation(): JSX.Element {
return ( return (
<DonationErrorModal <DonationErrorModal

View file

@ -25,11 +25,6 @@ export function DonationErrorModal(props: PropsType): JSX.Element {
let body: ReactNode; let body: ReactNode;
switch (props.errorType) { switch (props.errorType) {
case donationErrorTypeSchema.Enum.DonationProcessingError: {
title = i18n('icu:Donations__ErrorProcessingDonation');
body = i18n('icu:Donations__ErrorProcessingDonation__Description');
break;
}
case donationErrorTypeSchema.Enum.Failed3dsValidation: { case donationErrorTypeSchema.Enum.Failed3dsValidation: {
title = i18n('icu:Donations__Failed3dsValidation'); title = i18n('icu:Donations__Failed3dsValidation');
body = i18n('icu:Donations__Failed3dsValidation__Description'); body = i18n('icu:Donations__Failed3dsValidation__Description');
@ -52,13 +47,14 @@ export function DonationErrorModal(props: PropsType): JSX.Element {
return ( return (
<Modal <Modal
hasXButton
i18n={i18n} i18n={i18n}
modalFooter={ modalFooter={
<Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button> <Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button>
} }
hasXButton
moduleClassName="DonationErrorModal" moduleClassName="DonationErrorModal"
modalName="DonationErrorModal" modalName="DonationErrorModal"
noMouseClose
onClose={onClose} onClose={onClose}
title={title} title={title}
> >

View file

@ -17,7 +17,7 @@ export default {
const defaultProps = { const defaultProps = {
i18n, i18n,
onClose: action('onClose'), onWaitedTooLong: action('onWaitedTooLong'),
}; };
export function Default(): JSX.Element { export function Default(): JSX.Element {

View file

@ -1,26 +1,41 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useEffect } from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { SpinnerV2 } from './SpinnerV2'; import { SpinnerV2 } from './SpinnerV2';
import { SECOND } from '../util/durations';
export type PropsType = { export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onClose: () => void; onWaitedTooLong: () => void;
}; };
export function DonationProgressModal(props: PropsType): JSX.Element { export function DonationProgressModal(props: PropsType): JSX.Element {
const { i18n, onClose } = props; const { i18n, onWaitedTooLong } = props;
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = setTimeout(() => {
timeout = undefined;
onWaitedTooLong();
}, SECOND * 30);
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
}, [onWaitedTooLong]);
return ( return (
<Modal <Modal
i18n={i18n} i18n={i18n}
moduleClassName="DonationProgressModal" moduleClassName="DonationProgressModal"
modalName="DonationProgressModal" modalName="DonationProgressModal"
onClose={onClose} noMouseClose
onClose={() => undefined}
> >
<SpinnerV2 size={58} strokeWidth={8} /> <SpinnerV2 size={58} strokeWidth={8} />
<div className="DonationProgressModal__text"> <div className="DonationProgressModal__text">

View file

@ -17,13 +17,14 @@ export function DonationStillProcessingModal(props: PropsType): JSX.Element {
return ( return (
<Modal <Modal
hasXButton
i18n={i18n} i18n={i18n}
modalFooter={ modalFooter={
<Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button> <Button onClick={onClose}>{i18n('icu:Confirmation--confirm')}</Button>
} }
hasXButton
moduleClassName="DonationStillProcessingModal"
modalName="DonationStillProcessingModal" modalName="DonationStillProcessingModal"
moduleClassName="DonationStillProcessingModal"
noMouseClose
onClose={onClose} onClose={onClose}
title={i18n('icu:Donations__StillProcessing')} title={i18n('icu:Donations__StillProcessing')}
> >

View file

@ -17,7 +17,7 @@ export default {
const defaultProps = { const defaultProps = {
i18n, i18n,
onCancel: action('onCancel'), onCancelDonation: action('onCancelDonation'),
onOpenBrowser: action('onOpenBrowser'), onOpenBrowser: action('onOpenBrowser'),
}; };

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useState } from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
@ -9,33 +9,46 @@ import { Button, ButtonVariant } from './Button';
export type PropsType = { export type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onCancel: () => unknown; onCancelDonation: () => unknown;
onOpenBrowser: () => unknown; onOpenBrowser: () => unknown;
}; };
export function DonationVerificationModal(props: PropsType): JSX.Element { export function DonationVerificationModal(props: PropsType): JSX.Element {
const { i18n, onCancel, onOpenBrowser } = props; const { i18n, onCancelDonation, onOpenBrowser } = props;
const [hasOpenedBrowser, setHasOpenedBrowser] = useState(false);
const titleText = hasOpenedBrowser
? i18n('icu:Donations__3dsValidationNeeded--waiting')
: i18n('icu:Donations__3dsValidationNeeded');
const openBrowserText = hasOpenedBrowser
? i18n('icu:Donations__3dsValidationNeeded__OpenBrowser--opened')
: i18n('icu:Donations__3dsValidationNeeded__OpenBrowser');
const footer = ( const footer = (
<> <>
<Button variant={ButtonVariant.Secondary} onClick={onCancel}> <Button variant={ButtonVariant.Secondary} onClick={onCancelDonation}>
{i18n('icu:confirmation-dialog--Cancel')} {i18n('icu:Donations__3dsValidationNeeded__CancelDonation')}
</Button> </Button>
<Button onClick={onOpenBrowser}> <Button
{i18n('icu:Donations__3dsValidationNeeded__OpenBrowser')} onClick={() => {
setHasOpenedBrowser(true);
onOpenBrowser();
}}
>
{openBrowserText}
</Button> </Button>
</> </>
); );
return ( return (
<Modal <Modal
hasXButton
i18n={i18n} i18n={i18n}
modalFooter={footer} modalFooter={footer}
moduleClassName="DonationVerificationModal" moduleClassName="DonationVerificationModal"
modalName="DonationVerificationModal" modalName="DonationVerificationModal"
onClose={onCancel} noMouseClose
title={i18n('icu:Donations__3dsValidationNeeded')} onClose={onCancelDonation}
title={titleText}
> >
{i18n('icu:Donations__3dsValidationNeeded__Description')} {i18n('icu:Donations__3dsValidationNeeded__Description')}
</Modal> </Modal>

View file

@ -2,7 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { Meta, StoryFn } from '@storybook/react'; import type { Meta, StoryFn } from '@storybook/react';
import React, { useRef, useState } from 'react'; import React, { useState } from 'react';
import type { MutableRefObject } from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { shuffle } from 'lodash'; import { shuffle } from 'lodash';
@ -146,8 +147,11 @@ function renderUpdateDialog(
/> />
); );
} }
function RenderProfileEditor(): JSX.Element { function renderProfileEditor({
const contentsRef = useRef<HTMLDivElement | null>(null); contentsRef,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
}): JSX.Element {
return ( return (
<ProfileEditor <ProfileEditor
aboutEmoji={undefined} aboutEmoji={undefined}
@ -192,10 +196,12 @@ function RenderProfileEditor(): JSX.Element {
); );
} }
function RenderDonationsPane(props: { function renderDonationsPane(props: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
me: typeof me; me: typeof me;
donationReceipts: ReadonlyArray<DonationReceipt>; donationReceipts: ReadonlyArray<DonationReceipt>;
page: SettingsPage;
saveAttachmentToDisk: (options: { saveAttachmentToDisk: (options: {
data: Uint8Array; data: Uint8Array;
name: string; name: string;
@ -207,16 +213,16 @@ function RenderDonationsPane(props: {
) => Promise<Blob>; ) => Promise<Blob>;
showToast: (toast: AnyToast) => void; showToast: (toast: AnyToast) => void;
}): JSX.Element { }): JSX.Element {
const contentsRef = useRef<HTMLDivElement | null>(null);
return ( return (
<PreferencesDonations <PreferencesDonations
i18n={i18n} i18n={i18n}
contentsRef={contentsRef} contentsRef={props.contentsRef}
clearWorkflow={action('clearWorkflow')} clearWorkflow={action('clearWorkflow')}
isStaging isStaging
page={props.page} page={props.page}
setPage={action('setPage')} setPage={props.setPage}
submitDonation={action('submitDonation')} submitDonation={action('submitDonation')}
lastError={undefined}
workflow={undefined} workflow={undefined}
badge={undefined} badge={undefined}
color={props.me.color} color={props.me.color}
@ -229,6 +235,7 @@ function RenderDonationsPane(props: {
generateDonationReceiptBlob={props.generateDonationReceiptBlob} generateDonationReceiptBlob={props.generateDonationReceiptBlob}
showToast={props.showToast} showToast={props.showToast}
theme={ThemeType.light} theme={ThemeType.light}
updateLastError={action('updateLastError')}
/> />
); );
} }
@ -351,11 +358,21 @@ export default {
whoCanSeeMe: PhoneNumberSharingMode.Everybody, whoCanSeeMe: PhoneNumberSharingMode.Everybody,
zoomFactor: 1, zoomFactor: 1,
renderDonationsPane: () => renderDonationsPane: ({
RenderDonationsPane({ contentsRef,
page,
setPage,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
}) =>
renderDonationsPane({
contentsRef,
page,
setPage,
me, me,
donationReceipts: [], donationReceipts: [],
page: SettingsPage.Donations,
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')(); action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; return { fullPath: '/mock/path/to/file.png', name: 'file.png' };
@ -366,7 +383,7 @@ export default {
}, },
showToast: action('showToast'), showToast: action('showToast'),
}), }),
renderProfileEditor: RenderProfileEditor, renderProfileEditor,
renderToastManager, renderToastManager,
renderUpdateDialog, renderUpdateDialog,
getConversationsWithCustomColor: () => [], getConversationsWithCustomColor: () => [],
@ -473,7 +490,17 @@ export default {
// eslint-disable-next-line react/function-component-definition // eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => { const Template: StoryFn<PropsType> = args => {
const [page, setPage] = useState(args.page); const [page, setPage] = useState(args.page);
return <Preferences {...args} page={page} setPage={setPage} />; return (
<Preferences
{...args}
page={page}
setPage={(newPage: SettingsPage, profilePage?: ProfileEditorPage) => {
// eslint-disable-next-line no-console
console.log('setPage:', newPage, profilePage);
setPage(newPage);
}}
/>
);
}; };
export const _Preferences = Template.bind({}); export const _Preferences = Template.bind({});
@ -523,11 +550,19 @@ export const DonationsDonateFlow = Template.bind({});
DonationsDonateFlow.args = { DonationsDonateFlow.args = {
donationsFeatureEnabled: true, donationsFeatureEnabled: true,
page: SettingsPage.DonationsDonateFlow, page: SettingsPage.DonationsDonateFlow,
renderDonationsPane: () => renderDonationsPane: ({
RenderDonationsPane({ contentsRef,
}: {
contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage;
setPage: (page: SettingsPage) => void;
}) =>
renderDonationsPane({
contentsRef,
me, me,
donationReceipts: [], donationReceipts: [],
page: SettingsPage.DonationsDonateFlow, page: SettingsPage.DonationsDonateFlow,
setPage: action('setPage'),
saveAttachmentToDisk: async () => { saveAttachmentToDisk: async () => {
action('saveAttachmentToDisk')(); action('saveAttachmentToDisk')();
return { fullPath: '/mock/path/to/file.png', name: 'file.png' }; return { fullPath: '/mock/path/to/file.png', name: 'file.png' };

View file

@ -103,6 +103,7 @@ import {
isChatFoldersEnabled, isChatFoldersEnabled,
} from '../types/ChatFolder'; } from '../types/ChatFolder';
import type { GetConversationByIdType } from '../state/selectors/conversations'; import type { GetConversationByIdType } from '../state/selectors/conversations';
import type { ProfileEditorPage } from '../types/Nav';
type CheckboxChangeHandlerType = (value: boolean) => unknown; type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown; type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
@ -210,7 +211,7 @@ type PropsFunctionType = {
renderDonationsPane: (options: { renderDonationsPane: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;
page: SettingsPage; page: SettingsPage;
setPage: (page: SettingsPage) => void; setPage: (page: SettingsPage, profilePage?: ProfileEditorPage) => void;
}) => JSX.Element; }) => JSX.Element;
renderProfileEditor: (options: { renderProfileEditor: (options: {
contentsRef: MutableRefObject<HTMLDivElement | null>; contentsRef: MutableRefObject<HTMLDivElement | null>;

View file

@ -14,7 +14,10 @@ import classNames from 'classnames';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { useConfirmDiscard } from '../hooks/useConfirmDiscard'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import type { HumanDonationAmount } from '../types/Donations'; import type {
DonationErrorType,
HumanDonationAmount,
} from '../types/Donations';
import { import {
ONE_TIME_DONATION_CONFIG_ID, ONE_TIME_DONATION_CONFIG_ID,
type DonationWorkflow, type DonationWorkflow,
@ -63,6 +66,7 @@ const SUPPORT_URL = 'https://support.signal.org/hc/requests/new?desktop';
export type PropsDataType = { export type PropsDataType = {
i18n: LocalizerType; i18n: LocalizerType;
donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; donationAmountsConfig: OneTimeDonationHumanAmounts | undefined;
lastError: DonationErrorType | undefined;
validCurrencies: ReadonlyArray<string>; validCurrencies: ReadonlyArray<string>;
workflow: DonationWorkflow | undefined; workflow: DonationWorkflow | undefined;
renderDonationHero: () => JSX.Element; renderDonationHero: () => JSX.Element;
@ -84,6 +88,7 @@ export function PreferencesDonateFlow({
contentsRef, contentsRef,
i18n, i18n,
donationAmountsConfig, donationAmountsConfig,
lastError,
validCurrencies, validCurrencies,
workflow, workflow,
clearWorkflow, clearWorkflow,
@ -105,6 +110,7 @@ export function PreferencesDonateFlow({
const [cardExpiration, setCardExpiration] = useState(''); const [cardExpiration, setCardExpiration] = useState('');
const [cardNumber, setCardNumber] = useState(''); const [cardNumber, setCardNumber] = useState('');
const [cardCvc, setCardCvc] = useState(''); const [cardCvc, setCardCvc] = useState('');
const [isDonateDisabled, setIsDonateDisabled] = useState(false);
const [cardNumberError, setCardNumberError] = const [cardNumberError, setCardNumberError] =
useState<CardNumberError | null>(null); useState<CardNumberError | null>(null);
@ -183,22 +189,45 @@ export function PreferencesDonateFlow({
return; return;
} }
setIsDonateDisabled(true);
submitDonation({ submitDonation({
currencyType: currency, currencyType: currency,
paymentAmount, paymentAmount,
paymentDetail: cardDetail, paymentDetail: cardDetail,
}); });
}, [amount, cardCvc, cardExpiration, cardNumber, currency, submitDonation]); }, [
amount,
cardCvc,
cardExpiration,
cardNumber,
currency,
setIsDonateDisabled,
submitDonation,
]);
const isDonateDisabled = workflow !== undefined; useEffect(() => {
if (!workflow || lastError) {
setIsDonateDisabled(false);
}
}, [lastError, setIsDonateDisabled, workflow]);
const onTryClose = useCallback(() => { const onTryClose = useCallback(() => {
const onDiscard = () => { const onDiscard = () => {
// TODO: DESKTOP-8950 clearWorkflow();
}; };
const isDirty = Boolean(
(cardExpiration || cardNumber || cardCvc) && !isDonateDisabled
);
confirmDiscardIf(step === 'paymentDetails', onDiscard); confirmDiscardIf(isDirty, onDiscard);
}, [confirmDiscardIf, step]); }, [
cardCvc,
cardExpiration,
cardNumber,
clearWorkflow,
confirmDiscardIf,
isDonateDisabled,
]);
tryClose.current = onTryClose; tryClose.current = onTryClose;
let innerContent: JSX.Element; let innerContent: JSX.Element;

View file

@ -1,7 +1,7 @@
// Copyright 2025 Signal Messenger, LLC // Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useState } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import { groupBy, sortBy } from 'lodash'; import { groupBy, sortBy } from 'lodash';
import type { MutableRefObject, ReactNode } from 'react'; import type { MutableRefObject, ReactNode } from 'react';
@ -16,7 +16,9 @@ import type {
DonationWorkflow, DonationWorkflow,
DonationReceipt, DonationReceipt,
OneTimeDonationHumanAmounts, OneTimeDonationHumanAmounts,
DonationErrorType,
} from '../types/Donations'; } from '../types/Donations';
import { donationStateSchema } from '../types/Donations';
import type { AvatarColorType } from '../types/Colors'; import type { AvatarColorType } from '../types/Colors';
import { Button, ButtonSize, ButtonVariant } from './Button'; import { Button, ButtonSize, ButtonVariant } from './Button';
import { Modal } from './Modal'; import { Modal } from './Modal';
@ -32,6 +34,10 @@ import type { SubmitDonationType } from '../state/ducks/donations';
import { getHumanDonationAmount } from '../util/currency'; import { getHumanDonationAmount } from '../util/currency';
import { Avatar, AvatarSize } from './Avatar'; import { Avatar, AvatarSize } from './Avatar';
import type { BadgeType } from '../badges/types'; import type { BadgeType } from '../badges/types';
import { DonationErrorModal } from './DonationErrorModal';
import { DonationVerificationModal } from './DonationVerificationModal';
import { DonationProgressModal } from './DonationProgressModal';
import { DonationStillProcessingModal } from './DonationStillProcessingModal';
const log = createLogger('PreferencesDonations'); const log = createLogger('PreferencesDonations');
@ -43,6 +49,7 @@ export type PropsDataType = {
i18n: LocalizerType; i18n: LocalizerType;
isStaging: boolean; isStaging: boolean;
page: SettingsPage; page: SettingsPage;
lastError: DonationErrorType | undefined;
workflow: DonationWorkflow | undefined; workflow: DonationWorkflow | undefined;
badge: BadgeType | undefined; badge: BadgeType | undefined;
color: AvatarColorType | undefined; color: AvatarColorType | undefined;
@ -68,6 +75,7 @@ type PropsActionType = {
clearWorkflow: () => void; clearWorkflow: () => void;
setPage: (page: SettingsPage) => void; setPage: (page: SettingsPage) => void;
submitDonation: (payload: SubmitDonationType) => void; submitDonation: (payload: SubmitDonationType) => void;
updateLastError: (error: DonationErrorType | undefined) => void;
}; };
export type PropsType = PropsDataType & PropsActionType & PropsExternalType; export type PropsType = PropsDataType & PropsActionType & PropsExternalType;
@ -447,6 +455,7 @@ export function PreferencesDonations({
isStaging, isStaging,
page, page,
workflow, workflow,
lastError,
clearWorkflow, clearWorkflow,
setPage, setPage,
submitDonation, submitDonation,
@ -461,7 +470,10 @@ export function PreferencesDonations({
saveAttachmentToDisk, saveAttachmentToDisk,
generateDonationReceiptBlob, generateDonationReceiptBlob,
showToast, showToast,
updateLastError,
}: PropsType): JSX.Element | null { }: PropsType): JSX.Element | null {
const [hasProcessingExpired, setHasProcessingExpired] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const navigateToPage = useCallback( const navigateToPage = useCallback(
(newPage: SettingsPage) => { (newPage: SettingsPage) => {
setPage(newPage); setPage(newPage);
@ -469,6 +481,20 @@ export function PreferencesDonations({
[setPage] [setPage]
); );
useEffect(() => {
if (!workflow || 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( const renderDonationHero = useCallback(
() => ( () => (
<DonationHero <DonationHero
@ -487,21 +513,87 @@ export function PreferencesDonations({
return null; return null;
} }
let dialog: ReactNode | undefined;
if (lastError) {
dialog = (
<DonationErrorModal
errorType={lastError}
i18n={i18n}
onClose={() => {
updateLastError(undefined);
}}
/>
);
} else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) {
dialog = (
<DonationVerificationModal
i18n={i18n}
onCancelDonation={() => {
clearWorkflow();
setPage(SettingsPage.Donations);
showToast({ toastType: ToastType.DonationCancelled });
}}
onOpenBrowser={() => {
openLinkInWebBrowser(workflow.redirectTarget);
}}
/>
);
} 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 = (
<DonationStillProcessingModal
i18n={i18n}
onClose={() => {
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 = (
<DonationProgressModal
i18n={i18n}
onWaitedTooLong={() => setHasProcessingExpired(true)}
/>
);
}
}
let content; let content;
if (page === SettingsPage.DonationsDonateFlow) { if (page === SettingsPage.DonationsDonateFlow) {
// DonateFlow has to control Back button to switch between CC form and Amount picker // DonateFlow has to control Back button to switch between CC form and Amount picker
return ( return (
<>
{dialog}
<PreferencesDonateFlow <PreferencesDonateFlow
contentsRef={contentsRef} contentsRef={contentsRef}
i18n={i18n} i18n={i18n}
donationAmountsConfig={donationAmountsConfig} donationAmountsConfig={donationAmountsConfig}
lastError={lastError}
validCurrencies={validCurrencies} validCurrencies={validCurrencies}
workflow={workflow} workflow={workflow}
clearWorkflow={clearWorkflow} clearWorkflow={clearWorkflow}
renderDonationHero={renderDonationHero} renderDonationHero={renderDonationHero}
submitDonation={submitDonation} submitDonation={details => {
setIsSubmitted(true);
submitDonation(details);
}}
onBack={() => setPage(SettingsPage.Donations)} onBack={() => setPage(SettingsPage.Donations)}
/> />
</>
); );
} }
if (page === SettingsPage.Donations) { if (page === SettingsPage.Donations) {
@ -521,11 +613,13 @@ export function PreferencesDonations({
showToast={showToast} showToast={showToast}
isStaging={isStaging} isStaging={isStaging}
page={page} page={page}
lastError={lastError}
workflow={workflow} workflow={workflow}
clearWorkflow={clearWorkflow} clearWorkflow={clearWorkflow}
renderDonationHero={renderDonationHero} renderDonationHero={renderDonationHero}
setPage={setPage} setPage={setPage}
submitDonation={submitDonation} submitDonation={submitDonation}
updateLastError={updateLastError}
/> />
); );
} else if (page === SettingsPage.DonationsReceiptList) { } else if (page === SettingsPage.DonationsReceiptList) {
@ -557,11 +651,14 @@ export function PreferencesDonations({
} }
return ( return (
<>
{dialog}
<PreferencesContent <PreferencesContent
backButton={backButton} backButton={backButton}
contents={content} contents={content}
contentsRef={contentsRef} contentsRef={contentsRef}
title={title} title={title}
/> />
</>
); );
} }

View file

@ -100,10 +100,18 @@ function getToast(toastType: ToastType): AnyToast {
}; };
case ToastType.DeleteForEveryoneFailed: case ToastType.DeleteForEveryoneFailed:
return { toastType: ToastType.DeleteForEveryoneFailed }; return { toastType: ToastType.DeleteForEveryoneFailed };
case ToastType.DonationCancelled:
return { toastType: ToastType.DonationCancelled };
case ToastType.DonationCompleted: case ToastType.DonationCompleted:
return { toastType: ToastType.DonationCompleted }; return { toastType: ToastType.DonationCompleted };
case ToastType.DonationError:
return { toastType: ToastType.DonationError };
case ToastType.DonationProcessing: case ToastType.DonationProcessing:
return { toastType: ToastType.DonationProcessing }; return { toastType: ToastType.DonationProcessing };
case ToastType.DonationVerificationFailed:
return { toastType: ToastType.DonationVerificationFailed };
case ToastType.DonationVerificationNeeded:
return { toastType: ToastType.DonationVerificationNeeded };
case ToastType.Error: case ToastType.Error:
return { toastType: ToastType.Error }; return { toastType: ToastType.Error };
case ToastType.Expired: case ToastType.Expired:

View file

@ -280,6 +280,14 @@ export function renderToast({
); );
} }
if (toastType === ToastType.DonationCancelled) {
return (
<Toast onClose={hideToast}>
{i18n('icu:Donations__Toast__Cancelled')}
</Toast>
);
}
if (toastType === ToastType.DonationCompleted) { if (toastType === ToastType.DonationCompleted) {
return ( return (
<Toast <Toast
@ -328,6 +336,43 @@ export function renderToast({
); );
} }
if (
toastType === ToastType.DonationError ||
toastType === ToastType.DonationVerificationFailed ||
toastType === ToastType.DonationVerificationNeeded
) {
const mapping = {
[ToastType.DonationError]: i18n('icu:Donations__Toast__Error'),
[ToastType.DonationVerificationFailed]: i18n(
'icu:Donations__Toast__VerificationFailed'
),
[ToastType.DonationVerificationNeeded]: i18n(
'icu:Donations__Toast__VerificationNeeded'
),
};
const text = mapping[toastType];
return (
<Toast
onClose={hideToast}
toastAction={{
label: i18n('icu:view'),
onClick: () => {
changeLocation({
tab: NavTab.Settings,
details: {
page: SettingsPage.Donations,
},
});
},
}}
>
{text}
</Toast>
);
}
if (toastType === ToastType.Error) { if (toastType === ToastType.Error) {
return ( return (
<Toast <Toast

View file

@ -300,6 +300,14 @@ export async function _runDonationWorkflow(): Promise<void> {
log.info( log.info(
`${logId}: Waiting for user to return from confirmation URL. Returning.` `${logId}: Waiting for user to return from confirmation URL. Returning.`
); );
if (!isDonationPageVisible()) {
log.info(
`${logId}: Donation page not visible. Showing verification needed toast.`
);
window.reduxActions.toast.showToast({
toastType: ToastType.DonationVerificationNeeded,
});
}
return; return;
} else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) { } else if (type === donationStateSchema.Enum.INTENT_CONFIRMED) {
log.info(`${logId}: Attempting to get receipt`); log.info(`${logId}: Attempting to get receipt`);
@ -346,9 +354,7 @@ export async function _runDonationWorkflow(): Promise<void> {
if (type === donationStateSchema.Enum.INTENT_METHOD) { if (type === donationStateSchema.Enum.INTENT_METHOD) {
await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined); await failDonation(donationErrorTypeSchema.Enum.PaymentDeclined);
} else { } else {
await failDonation( await failDonation(donationErrorTypeSchema.Enum.GeneralError);
donationErrorTypeSchema.Enum.DonationProcessingError
);
} }
throw error; throw error;
} }
@ -360,9 +366,7 @@ export async function _runDonationWorkflow(): Promise<void> {
log.warn( log.warn(
`${logId}: Donation step threw unexpectedly. Failing donation. ${Errors.toLogFormat(error)}` `${logId}: Donation step threw unexpectedly. Failing donation. ${Errors.toLogFormat(error)}`
); );
await failDonation( await failDonation(donationErrorTypeSchema.Enum.GeneralError);
donationErrorTypeSchema.Enum.DonationProcessingError
);
throw error; throw error;
} }
} }
@ -723,6 +727,7 @@ export async function _redeemReceipt(
async function failDonation(errorType: DonationErrorType): Promise<void> { async function failDonation(errorType: DonationErrorType): Promise<void> {
const workflow = _getWorkflowFromRedux(); const workflow = _getWorkflowFromRedux();
const logId = `failDonation(${workflow?.id ? redactId(workflow.id) : 'NONE'})`;
// We clear the workflow if we didn't just get user input // We clear the workflow if we didn't just get user input
if ( if (
@ -735,6 +740,24 @@ async function failDonation(errorType: DonationErrorType): Promise<void> {
} }
log.info(`failDonation: Failing with type ${errorType}`); log.info(`failDonation: Failing with type ${errorType}`);
if (!isDonationPageVisible()) {
if (errorType === donationErrorTypeSchema.Enum.Failed3dsValidation) {
log.info(
`${logId}: Donation page not visible. Showing 'verification failed' toast.`
);
window.reduxActions.toast.showToast({
toastType: ToastType.DonationVerificationFailed,
});
} else {
log.info(
`${logId}: Donation page not visible. Showing 'error processing donation' toast.`
);
window.reduxActions.toast.showToast({
toastType: ToastType.DonationError,
});
}
}
window.reduxActions.donations.updateLastError(errorType); window.reduxActions.donations.updateLastError(errorType);
} }
async function _saveWorkflow( async function _saveWorkflow(

View file

@ -10,6 +10,8 @@ import * as Errors from '../../types/errors';
import { isStagingServer } from '../../util/isStagingServer'; import { isStagingServer } from '../../util/isStagingServer';
import { DataWriter } from '../../sql/Client'; import { DataWriter } from '../../sql/Client';
import * as donations from '../../services/donations'; import * as donations from '../../services/donations';
import { donationStateSchema } from '../../types/Donations';
import { drop } from '../../util/drop';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import type { import type {
@ -20,7 +22,6 @@ import type {
StripeDonationAmount, StripeDonationAmount,
} from '../../types/Donations'; } from '../../types/Donations';
import type { StateType as RootStateType } from '../reducer'; import type { StateType as RootStateType } from '../reducer';
import { drop } from '../../util/drop';
const log = createLogger('donations'); const log = createLogger('donations');
@ -28,9 +29,9 @@ const log = createLogger('donations');
export type DonationsStateType = ReadonlyDeep<{ export type DonationsStateType = ReadonlyDeep<{
currentWorkflow: DonationWorkflow | undefined; currentWorkflow: DonationWorkflow | undefined;
didResumeWorkflowAtStartup: boolean;
lastError: DonationErrorType | undefined; lastError: DonationErrorType | undefined;
receipts: Array<DonationReceipt>; receipts: Array<DonationReceipt>;
didResumeWorkflowAtStartup: boolean;
}>; }>;
// Actions // Actions
@ -129,18 +130,29 @@ function submitDonation({
unknown, unknown,
UpdateWorkflowAction UpdateWorkflowAction
> { > {
return async () => { return async (_dispatch, getState) => {
if (!isStagingServer()) { if (!isStagingServer()) {
log.error('internalAddDonationReceipt: Only available on staging server'); log.error('internalAddDonationReceipt: Only available on staging server');
return; return;
} }
try { try {
await donations._internalDoDonation({ const { currentWorkflow } = getState().donations;
if (
currentWorkflow?.type === donationStateSchema.Enum.INTENT &&
currentWorkflow.paymentAmount === paymentAmount &&
currentWorkflow.currencyType === currencyType
) {
// we can proceed without starting afresh
} else {
await donations.clearDonation();
await donations.startDonation({
currencyType, currencyType,
paymentAmount, paymentAmount,
paymentDetail,
}); });
}
await donations.finishDonationWithCard(paymentDetail);
} catch (error) { } catch (error) {
log.warn('submitDonation failed', Errors.toLogFormat(error)); log.warn('submitDonation failed', Errors.toLogFormat(error));
} }

View file

@ -41,10 +41,9 @@ export const SmartPreferencesDonations = memo(
const i18n = useSelector(getIntl); const i18n = useSelector(getIntl);
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const workflow = useSelector( const donationsState = useSelector((state: StateType) => state.donations);
(state: StateType) => state.donations.currentWorkflow const { clearWorkflow, submitDonation, updateLastError } =
); useDonationsActions();
const { clearWorkflow, submitDonation } = useDonationsActions();
const { badges, color, firstName, profileAvatarUrl } = useSelector(getMe); const { badges, color, firstName, profileAvatarUrl } = useSelector(getMe);
const badge = getPreferredBadge(badges); const badge = getPreferredBadge(badges);
@ -84,8 +83,10 @@ export const SmartPreferencesDonations = memo(
contentsRef={contentsRef} contentsRef={contentsRef}
isStaging={isStaging} isStaging={isStaging}
page={page} page={page}
workflow={workflow} lastError={donationsState.lastError}
workflow={donationsState.currentWorkflow}
clearWorkflow={clearWorkflow} clearWorkflow={clearWorkflow}
updateLastError={updateLastError}
submitDonation={submitDonation} submitDonation={submitDonation}
setPage={setPage} setPage={setPage}
theme={theme} theme={theme}

View file

@ -15,8 +15,6 @@ export const donationStateSchema = z.enum([
]); ]);
export const donationErrorTypeSchema = z.enum([ export const donationErrorTypeSchema = z.enum([
// Any other HTTPError during the process
'DonationProcessingError',
// Used if the user is redirected back from validation, but continuing forward fails // Used if the user is redirected back from validation, but continuing forward fails
'Failed3dsValidation', 'Failed3dsValidation',
// Any other error // Any other error

View file

@ -31,8 +31,12 @@ export enum ToastType {
DecryptionError = 'DecryptionError', DecryptionError = 'DecryptionError',
DebugLogError = 'DebugLogError', DebugLogError = 'DebugLogError',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
DonationCancelled = 'DonationCancelled',
DonationCompleted = 'DonationCompleted', DonationCompleted = 'DonationCompleted',
DonationError = 'DonationError',
DonationProcessing = 'DonationProcessing', DonationProcessing = 'DonationProcessing',
DonationVerificationNeeded = 'DonationVerificationNeeded',
DonationVerificationFailed = 'DonationVerificationFailed',
Error = 'Error', Error = 'Error',
Expired = 'Expired', Expired = 'Expired',
FailedToDeleteUsername = 'FailedToDeleteUsername', FailedToDeleteUsername = 'FailedToDeleteUsername',
@ -122,8 +126,12 @@ export type AnyToast =
| { toastType: ToastType.DangerousFileType } | { toastType: ToastType.DangerousFileType }
| { toastType: ToastType.DebugLogError } | { toastType: ToastType.DebugLogError }
| { toastType: ToastType.DeleteForEveryoneFailed } | { toastType: ToastType.DeleteForEveryoneFailed }
| { toastType: ToastType.DonationCancelled }
| { toastType: ToastType.DonationCompleted } | { toastType: ToastType.DonationCompleted }
| { toastType: ToastType.DonationError }
| { toastType: ToastType.DonationProcessing } | { toastType: ToastType.DonationProcessing }
| { toastType: ToastType.DonationVerificationFailed }
| { toastType: ToastType.DonationVerificationNeeded }
| { toastType: ToastType.Error } | { toastType: ToastType.Error }
| { toastType: ToastType.Expired } | { toastType: ToastType.Expired }
| { toastType: ToastType.FailedToDeleteUsername } | { toastType: ToastType.FailedToDeleteUsername }

View file

@ -126,8 +126,8 @@ window.testUtilities = {
stories: [], stories: [],
storyDistributionLists: [], storyDistributionLists: [],
donations: { donations: {
didResumeWorkflowAtStartup: false,
currentWorkflow: undefined, currentWorkflow: undefined,
didResumeWorkflowAtStartup: false,
lastError: undefined, lastError: undefined,
receipts: [], receipts: [],
}, },