Donations: Show confirmation toast on startup at INTENT_METHOD

Co-authored-by: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com>
This commit is contained in:
Scott Nonnenberg
2025-07-30 07:35:10 +10:00
committed by GitHub
parent 0aec0e0f18
commit 004dfb0af4
13 changed files with 206 additions and 21 deletions

View File

@@ -8926,6 +8926,22 @@
"messageformat": "You have a donation in progress that needs additional verification.", "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." "description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup."
}, },
"icu:Donations__Toast__ConfirmationNeeded": {
"messageformat": "You have a donation in progress that requires confirmation.",
"description": "Shown when the user is not on the Preferences/Donations screen, and donation verification is needed. Like when resuming from startup."
},
"icu:Donations__DonationInterrupted": {
"messageformat": "Donation interrupted",
"description": "Title of the dialog shown when starting up if a donation had been started, and we've saved payment information, but the charge hasn't happened yet"
},
"icu:Donations__DonationInterrupted__Description": {
"messageformat": "Your card was not charged. Do you want to retry the donation?",
"description": "An explanation for the 'donation interrupted' dialog"
},
"icu:Donations__DonationInterrupted__RetryButton": {
"messageformat": "Try again",
"description": "The button in the 'donation interrupted' dialog which allows the user to move forward with the donation."
},
"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"

View File

@@ -0,0 +1,17 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
@use '../mixins';
.DonationInterruptedModal__width-container {
max-width: 420px;
}
// We include both for specificity
.module-Modal__title.DonationInterruptedModal__title {
@include mixins.font-title-medium;
}
.DonationInterruptedModal__body_inner {
@include mixins.font-body-2;
}

View File

@@ -98,6 +98,7 @@
@use 'components/DisappearingTimerSelect.scss'; @use 'components/DisappearingTimerSelect.scss';
@use 'components/DonationErrorModal.scss'; @use 'components/DonationErrorModal.scss';
@use 'components/DonationForm.scss'; @use 'components/DonationForm.scss';
@use 'components/DonationInterruptedModal.scss';
@use 'components/DonationProgressModal.scss'; @use 'components/DonationProgressModal.scss';
@use 'components/DonationStillProcessingModal.scss'; @use 'components/DonationStillProcessingModal.scss';
@use 'components/DonationVerificationModal.scss'; @use 'components/DonationVerificationModal.scss';

View File

@@ -0,0 +1,26 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { PropsType } from './DonationInterruptedModal';
import { DonationInterruptedModal } from './DonationInterruptedModal';
const { i18n } = window.SignalContext;
export default {
title: 'Components/DonationInterruptedModal',
} satisfies Meta<PropsType>;
const defaultProps = {
i18n,
onCancelDonation: action('onCancelDonation'),
onRetryDonation: action('onRetryDonation'),
};
export function Default(): JSX.Element {
return <DonationInterruptedModal {...defaultProps} />;
}

View File

@@ -0,0 +1,47 @@
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button';
export type PropsType = {
i18n: LocalizerType;
onCancelDonation: () => unknown;
onRetryDonation: () => unknown;
};
export function DonationInterruptedModal(props: PropsType): JSX.Element {
const { i18n, onCancelDonation, onRetryDonation } = props;
const footer = (
<>
<Button variant={ButtonVariant.Secondary} onClick={onCancelDonation}>
{i18n('icu:cancel')}
</Button>
<Button
onClick={() => {
onRetryDonation();
}}
>
{i18n('icu:Donations__DonationInterrupted__RetryButton')}
</Button>
</>
);
return (
<Modal
i18n={i18n}
modalFooter={footer}
moduleClassName="DonationInterruptedModal"
modalName="DonationInterruptedModal"
noMouseClose
onClose={onCancelDonation}
title={i18n('icu:Donations__DonationInterrupted')}
>
{i18n('icu:Donations__DonationInterrupted__Description')}
</Modal>
);
}

View File

@@ -219,12 +219,14 @@ function renderDonationsPane(props: {
contentsRef={props.contentsRef} contentsRef={props.contentsRef}
clearWorkflow={action('clearWorkflow')} clearWorkflow={action('clearWorkflow')}
initialCurrency="USD" initialCurrency="USD"
resumeWorkflow={action('resumeWorkflow')}
isStaging isStaging
page={props.page} page={props.page}
setPage={props.setPage} setPage={props.setPage}
submitDonation={action('submitDonation')} submitDonation={action('submitDonation')}
lastError={undefined} lastError={undefined}
workflow={undefined} workflow={undefined}
didResumeWorkflowAtStartup={false}
badge={undefined} badge={undefined}
color={props.me.color} color={props.me.color}
firstName={props.me.firstName} firstName={props.me.firstName}

View File

@@ -34,6 +34,7 @@ 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 { DonationInterruptedModal } from './DonationInterruptedModal';
import { DonationErrorModal } from './DonationErrorModal'; import { DonationErrorModal } from './DonationErrorModal';
import { DonationVerificationModal } from './DonationVerificationModal'; import { DonationVerificationModal } from './DonationVerificationModal';
import { DonationProgressModal } from './DonationProgressModal'; import { DonationProgressModal } from './DonationProgressModal';
@@ -50,6 +51,7 @@ export type PropsDataType = {
initialCurrency: string; initialCurrency: string;
isStaging: boolean; isStaging: boolean;
page: SettingsPage; page: SettingsPage;
didResumeWorkflowAtStartup: boolean;
lastError: DonationErrorType | undefined; lastError: DonationErrorType | undefined;
workflow: DonationWorkflow | undefined; workflow: DonationWorkflow | undefined;
badge: BadgeType | undefined; badge: BadgeType | undefined;
@@ -69,12 +71,13 @@ export type PropsDataType = {
receipt: DonationReceipt, receipt: DonationReceipt,
i18n: LocalizerType i18n: LocalizerType
) => Promise<Blob>; ) => Promise<Blob>;
showToast: (toast: AnyToast) => void;
}; };
type PropsActionType = { type PropsActionType = {
clearWorkflow: () => void; clearWorkflow: () => void;
resumeWorkflow: () => void;
setPage: (page: SettingsPage) => void; setPage: (page: SettingsPage) => void;
showToast: (toast: AnyToast) => void;
submitDonation: (payload: SubmitDonationType) => void; submitDonation: (payload: SubmitDonationType) => void;
updateLastError: (error: DonationErrorType | undefined) => void; updateLastError: (error: DonationErrorType | undefined) => void;
}; };
@@ -86,7 +89,10 @@ type DonationPage =
| SettingsPage.DonationsDonateFlow | SettingsPage.DonationsDonateFlow
| SettingsPage.DonationsReceiptList; | SettingsPage.DonationsReceiptList;
type PreferencesHomeProps = Omit<PropsType, 'badge' | 'theme'> & { type PreferencesHomeProps = Pick<
PropsType,
'contentsRef' | 'i18n' | 'setPage' | 'isStaging' | 'donationReceipts'
> & {
navigateToPage: (newPage: SettingsPage) => void; navigateToPage: (newPage: SettingsPage) => void;
renderDonationHero: () => JSX.Element; renderDonationHero: () => JSX.Element;
}; };
@@ -457,8 +463,10 @@ export function PreferencesDonations({
isStaging, isStaging,
page, page,
workflow, workflow,
didResumeWorkflowAtStartup,
lastError, lastError,
clearWorkflow, clearWorkflow,
resumeWorkflow,
setPage, setPage,
submitDonation, submitDonation,
badge, badge,
@@ -526,6 +534,23 @@ export function PreferencesDonations({
}} }}
/> />
); );
} else if (
didResumeWorkflowAtStartup &&
workflow?.type === donationStateSchema.Enum.INTENT_METHOD
) {
dialog = (
<DonationInterruptedModal
i18n={i18n}
onCancelDonation={() => {
clearWorkflow();
setPage(SettingsPage.Donations);
showToast({ toastType: ToastType.DonationCancelled });
}}
onRetryDonation={() => {
resumeWorkflow();
}}
/>
);
} else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) { } else if (workflow?.type === donationStateSchema.Enum.INTENT_REDIRECT) {
dialog = ( dialog = (
<DonationVerificationModal <DonationVerificationModal
@@ -604,26 +629,11 @@ export function PreferencesDonations({
<DonationsHome <DonationsHome
contentsRef={contentsRef} contentsRef={contentsRef}
i18n={i18n} i18n={i18n}
color={color}
firstName={firstName}
profileAvatarUrl={profileAvatarUrl}
navigateToPage={navigateToPage} navigateToPage={navigateToPage}
donationReceipts={donationReceipts} donationReceipts={donationReceipts}
donationAmountsConfig={donationAmountsConfig}
validCurrencies={validCurrencies}
saveAttachmentToDisk={saveAttachmentToDisk}
generateDonationReceiptBlob={generateDonationReceiptBlob}
showToast={showToast}
isStaging={isStaging} isStaging={isStaging}
initialCurrency={initialCurrency}
page={page}
lastError={lastError}
workflow={workflow}
clearWorkflow={clearWorkflow}
renderDonationHero={renderDonationHero} renderDonationHero={renderDonationHero}
setPage={setPage} setPage={setPage}
submitDonation={submitDonation}
updateLastError={updateLastError}
/> />
); );
} else if (page === SettingsPage.DonationsReceiptList) { } else if (page === SettingsPage.DonationsReceiptList) {

View File

@@ -104,6 +104,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.DonationCancelled }; return { toastType: ToastType.DonationCancelled };
case ToastType.DonationCompleted: case ToastType.DonationCompleted:
return { toastType: ToastType.DonationCompleted }; return { toastType: ToastType.DonationCompleted };
case ToastType.DonationConfirmationNeeded:
return { toastType: ToastType.DonationConfirmationNeeded };
case ToastType.DonationError: case ToastType.DonationError:
return { toastType: ToastType.DonationError }; return { toastType: ToastType.DonationError };
case ToastType.DonationProcessing: case ToastType.DonationProcessing:

View File

@@ -337,11 +337,15 @@ export function renderToast({
} }
if ( if (
toastType === ToastType.DonationConfirmationNeeded ||
toastType === ToastType.DonationError || toastType === ToastType.DonationError ||
toastType === ToastType.DonationVerificationFailed || toastType === ToastType.DonationVerificationFailed ||
toastType === ToastType.DonationVerificationNeeded toastType === ToastType.DonationVerificationNeeded
) { ) {
const mapping = { const mapping = {
[ToastType.DonationConfirmationNeeded]: i18n(
'icu:Donations__Toast__ConfirmationNeeded'
),
[ToastType.DonationError]: i18n('icu:Donations__Toast__Error'), [ToastType.DonationError]: i18n('icu:Donations__Toast__Error'),
[ToastType.DonationVerificationFailed]: i18n( [ToastType.DonationVerificationFailed]: i18n(
'icu:Donations__Toast__VerificationFailed' 'icu:Donations__Toast__VerificationFailed'
@@ -355,6 +359,7 @@ export function renderToast({
return ( return (
<Toast <Toast
autoDismissDisabled
onClose={hideToast} onClose={hideToast}
toastAction={{ toastAction={{
label: i18n('icu:view'), label: i18n('icu:view'),

View File

@@ -85,7 +85,24 @@ export async function initialize(): Promise<void> {
return; return;
} }
if (didResumeWorkflowAtStartup() && !isDonationPageVisible()) { const shouldShowToast =
didResumeWorkflowAtStartup() && !isDonationPageVisible();
if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) {
if (shouldShowToast) {
log.info(
'initialize: Showing confirmation toast, workflow is at INTENT_METHOD.'
);
window.reduxActions.toast.showToast({
toastType: ToastType.DonationConfirmationNeeded,
});
}
// Note that we are not starting the workflow here
return;
}
if (shouldShowToast) {
log.info( log.info(
'initialize: We resumed at startup and donation page not visible. Showing processing toast.' 'initialize: We resumed at startup and donation page not visible. Showing processing toast.'
); );
@@ -97,7 +114,7 @@ export async function initialize(): Promise<void> {
await _runDonationWorkflow(); await _runDonationWorkflow();
} }
// These are the four moments the user provides input to the donation workflow. So, // These are the five moments the user provides input to the donation workflow. So,
// UI calls these methods directly; everything else happens automatically. // UI calls these methods directly; everything else happens automatically.
export async function startDonation({ export async function startDonation({
@@ -169,6 +186,15 @@ export async function clearDonation(): Promise<void> {
await _saveWorkflow(undefined); await _saveWorkflow(undefined);
} }
export async function resumeDonation(): Promise<void> {
const existing = _getWorkflowFromRedux();
if (!existing) {
throw new Error('resumeDonation: Cannot finish nonexistent workflow!');
}
await _saveAndRunWorkflow(existing);
}
// For testing // For testing
export async function _internalDoDonation({ export async function _internalDoDonation({
@@ -295,6 +321,13 @@ export async function _runDonationWorkflow(): Promise<void> {
return; return;
} }
if (type === donationStateSchema.Enum.INTENT_METHOD) { if (type === donationStateSchema.Enum.INTENT_METHOD) {
if (didResumeWorkflowAtStartup()) {
log.info(
`${logId}: Resumed after startup and haven't charged payment method. Waiting for user confirmation.`
);
return;
}
log.info(`${logId}: Attempting to confirm payment`); log.info(`${logId}: Attempting to confirm payment`);
updated = await _confirmPayment(existing); updated = await _confirmPayment(existing);
// continuing // continuing

View File

@@ -114,6 +114,27 @@ function setDidResume(didResume: boolean): SetDidResumeAction {
}; };
} }
function resumeWorkflow(): ThunkAction<
void,
RootStateType,
unknown,
SetDidResumeAction
> {
return async dispatch => {
try {
dispatch({
type: SET_DID_RESUME,
payload: false,
});
await donations.resumeDonation();
} catch (error) {
log.error('Error resuming workflow', Errors.toLogFormat(error));
throw error;
}
};
}
export type SubmitDonationType = ReadonlyDeep<{ export type SubmitDonationType = ReadonlyDeep<{
currencyType: string; currencyType: string;
paymentAmount: StripeDonationAmount; paymentAmount: StripeDonationAmount;
@@ -132,7 +153,7 @@ function submitDonation({
> { > {
return async (_dispatch, getState) => { return async (_dispatch, getState) => {
if (!isStagingServer()) { if (!isStagingServer()) {
log.error('internalAddDonationReceipt: Only available on staging server'); log.error('submitDonation: Only available on staging server');
return; return;
} }
@@ -191,6 +212,7 @@ export const actions = {
clearWorkflow, clearWorkflow,
internalAddDonationReceipt, internalAddDonationReceipt,
setDidResume, setDidResume,
resumeWorkflow,
submitDonation, submitDonation,
updateLastError, updateLastError,
updateWorkflow, updateWorkflow,

View File

@@ -43,7 +43,7 @@ export const SmartPreferencesDonations = memo(
const theme = useSelector(getTheme); const theme = useSelector(getTheme);
const donationsState = useSelector((state: StateType) => state.donations); const donationsState = useSelector((state: StateType) => state.donations);
const { clearWorkflow, submitDonation, updateLastError } = const { clearWorkflow, resumeWorkflow, submitDonation, updateLastError } =
useDonationsActions(); useDonationsActions();
const ourNumber = useSelector(getUserNumber); const ourNumber = useSelector(getUserNumber);
@@ -93,9 +93,11 @@ export const SmartPreferencesDonations = memo(
initialCurrency={initialCurrency} initialCurrency={initialCurrency}
isStaging={isStaging} isStaging={isStaging}
page={page} page={page}
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
lastError={donationsState.lastError} lastError={donationsState.lastError}
workflow={donationsState.currentWorkflow} workflow={donationsState.currentWorkflow}
clearWorkflow={clearWorkflow} clearWorkflow={clearWorkflow}
resumeWorkflow={resumeWorkflow}
updateLastError={updateLastError} updateLastError={updateLastError}
submitDonation={submitDonation} submitDonation={submitDonation}
setPage={setPage} setPage={setPage}

View File

@@ -33,6 +33,7 @@ export enum ToastType {
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
DonationCancelled = 'DonationCancelled', DonationCancelled = 'DonationCancelled',
DonationCompleted = 'DonationCompleted', DonationCompleted = 'DonationCompleted',
DonationConfirmationNeeded = 'DonationConfirmationNeeded',
DonationError = 'DonationError', DonationError = 'DonationError',
DonationProcessing = 'DonationProcessing', DonationProcessing = 'DonationProcessing',
DonationVerificationNeeded = 'DonationVerificationNeeded', DonationVerificationNeeded = 'DonationVerificationNeeded',
@@ -128,6 +129,7 @@ export type AnyToast =
| { toastType: ToastType.DeleteForEveryoneFailed } | { toastType: ToastType.DeleteForEveryoneFailed }
| { toastType: ToastType.DonationCancelled } | { toastType: ToastType.DonationCancelled }
| { toastType: ToastType.DonationCompleted } | { toastType: ToastType.DonationCompleted }
| { toastType: ToastType.DonationConfirmationNeeded }
| { toastType: ToastType.DonationError } | { toastType: ToastType.DonationError }
| { toastType: ToastType.DonationProcessing } | { toastType: ToastType.DonationProcessing }
| { toastType: ToastType.DonationVerificationFailed } | { toastType: ToastType.DonationVerificationFailed }