mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
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:
@@ -8926,6 +8926,22 @@
|
||||
"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__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": {
|
||||
"messageformat": "Payment method declined",
|
||||
"description": "Title of the dialog shown with the user's provided payment method has not worked"
|
||||
|
||||
17
stylesheets/components/DonationInterruptedModal.scss
Normal file
17
stylesheets/components/DonationInterruptedModal.scss
Normal 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;
|
||||
}
|
||||
@@ -98,6 +98,7 @@
|
||||
@use 'components/DisappearingTimerSelect.scss';
|
||||
@use 'components/DonationErrorModal.scss';
|
||||
@use 'components/DonationForm.scss';
|
||||
@use 'components/DonationInterruptedModal.scss';
|
||||
@use 'components/DonationProgressModal.scss';
|
||||
@use 'components/DonationStillProcessingModal.scss';
|
||||
@use 'components/DonationVerificationModal.scss';
|
||||
|
||||
26
ts/components/DonationInterruptedModal.stories.tsx
Normal file
26
ts/components/DonationInterruptedModal.stories.tsx
Normal 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} />;
|
||||
}
|
||||
47
ts/components/DonationInterruptedModal.tsx
Normal file
47
ts/components/DonationInterruptedModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -219,12 +219,14 @@ function renderDonationsPane(props: {
|
||||
contentsRef={props.contentsRef}
|
||||
clearWorkflow={action('clearWorkflow')}
|
||||
initialCurrency="USD"
|
||||
resumeWorkflow={action('resumeWorkflow')}
|
||||
isStaging
|
||||
page={props.page}
|
||||
setPage={props.setPage}
|
||||
submitDonation={action('submitDonation')}
|
||||
lastError={undefined}
|
||||
workflow={undefined}
|
||||
didResumeWorkflowAtStartup={false}
|
||||
badge={undefined}
|
||||
color={props.me.color}
|
||||
firstName={props.me.firstName}
|
||||
|
||||
@@ -34,6 +34,7 @@ import type { SubmitDonationType } from '../state/ducks/donations';
|
||||
import { getHumanDonationAmount } from '../util/currency';
|
||||
import { Avatar, AvatarSize } from './Avatar';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { DonationInterruptedModal } from './DonationInterruptedModal';
|
||||
import { DonationErrorModal } from './DonationErrorModal';
|
||||
import { DonationVerificationModal } from './DonationVerificationModal';
|
||||
import { DonationProgressModal } from './DonationProgressModal';
|
||||
@@ -50,6 +51,7 @@ export type PropsDataType = {
|
||||
initialCurrency: string;
|
||||
isStaging: boolean;
|
||||
page: SettingsPage;
|
||||
didResumeWorkflowAtStartup: boolean;
|
||||
lastError: DonationErrorType | undefined;
|
||||
workflow: DonationWorkflow | undefined;
|
||||
badge: BadgeType | undefined;
|
||||
@@ -69,12 +71,13 @@ export type PropsDataType = {
|
||||
receipt: DonationReceipt,
|
||||
i18n: LocalizerType
|
||||
) => Promise<Blob>;
|
||||
showToast: (toast: AnyToast) => void;
|
||||
};
|
||||
|
||||
type PropsActionType = {
|
||||
clearWorkflow: () => void;
|
||||
resumeWorkflow: () => void;
|
||||
setPage: (page: SettingsPage) => void;
|
||||
showToast: (toast: AnyToast) => void;
|
||||
submitDonation: (payload: SubmitDonationType) => void;
|
||||
updateLastError: (error: DonationErrorType | undefined) => void;
|
||||
};
|
||||
@@ -86,7 +89,10 @@ type DonationPage =
|
||||
| SettingsPage.DonationsDonateFlow
|
||||
| SettingsPage.DonationsReceiptList;
|
||||
|
||||
type PreferencesHomeProps = Omit<PropsType, 'badge' | 'theme'> & {
|
||||
type PreferencesHomeProps = Pick<
|
||||
PropsType,
|
||||
'contentsRef' | 'i18n' | 'setPage' | 'isStaging' | 'donationReceipts'
|
||||
> & {
|
||||
navigateToPage: (newPage: SettingsPage) => void;
|
||||
renderDonationHero: () => JSX.Element;
|
||||
};
|
||||
@@ -457,8 +463,10 @@ export function PreferencesDonations({
|
||||
isStaging,
|
||||
page,
|
||||
workflow,
|
||||
didResumeWorkflowAtStartup,
|
||||
lastError,
|
||||
clearWorkflow,
|
||||
resumeWorkflow,
|
||||
setPage,
|
||||
submitDonation,
|
||||
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) {
|
||||
dialog = (
|
||||
<DonationVerificationModal
|
||||
@@ -604,26 +629,11 @@ export function PreferencesDonations({
|
||||
<DonationsHome
|
||||
contentsRef={contentsRef}
|
||||
i18n={i18n}
|
||||
color={color}
|
||||
firstName={firstName}
|
||||
profileAvatarUrl={profileAvatarUrl}
|
||||
navigateToPage={navigateToPage}
|
||||
donationReceipts={donationReceipts}
|
||||
donationAmountsConfig={donationAmountsConfig}
|
||||
validCurrencies={validCurrencies}
|
||||
saveAttachmentToDisk={saveAttachmentToDisk}
|
||||
generateDonationReceiptBlob={generateDonationReceiptBlob}
|
||||
showToast={showToast}
|
||||
isStaging={isStaging}
|
||||
initialCurrency={initialCurrency}
|
||||
page={page}
|
||||
lastError={lastError}
|
||||
workflow={workflow}
|
||||
clearWorkflow={clearWorkflow}
|
||||
renderDonationHero={renderDonationHero}
|
||||
setPage={setPage}
|
||||
submitDonation={submitDonation}
|
||||
updateLastError={updateLastError}
|
||||
/>
|
||||
);
|
||||
} else if (page === SettingsPage.DonationsReceiptList) {
|
||||
|
||||
@@ -104,6 +104,8 @@ function getToast(toastType: ToastType): AnyToast {
|
||||
return { toastType: ToastType.DonationCancelled };
|
||||
case ToastType.DonationCompleted:
|
||||
return { toastType: ToastType.DonationCompleted };
|
||||
case ToastType.DonationConfirmationNeeded:
|
||||
return { toastType: ToastType.DonationConfirmationNeeded };
|
||||
case ToastType.DonationError:
|
||||
return { toastType: ToastType.DonationError };
|
||||
case ToastType.DonationProcessing:
|
||||
|
||||
@@ -337,11 +337,15 @@ export function renderToast({
|
||||
}
|
||||
|
||||
if (
|
||||
toastType === ToastType.DonationConfirmationNeeded ||
|
||||
toastType === ToastType.DonationError ||
|
||||
toastType === ToastType.DonationVerificationFailed ||
|
||||
toastType === ToastType.DonationVerificationNeeded
|
||||
) {
|
||||
const mapping = {
|
||||
[ToastType.DonationConfirmationNeeded]: i18n(
|
||||
'icu:Donations__Toast__ConfirmationNeeded'
|
||||
),
|
||||
[ToastType.DonationError]: i18n('icu:Donations__Toast__Error'),
|
||||
[ToastType.DonationVerificationFailed]: i18n(
|
||||
'icu:Donations__Toast__VerificationFailed'
|
||||
@@ -355,6 +359,7 @@ export function renderToast({
|
||||
|
||||
return (
|
||||
<Toast
|
||||
autoDismissDisabled
|
||||
onClose={hideToast}
|
||||
toastAction={{
|
||||
label: i18n('icu:view'),
|
||||
|
||||
@@ -85,7 +85,24 @@ export async function initialize(): Promise<void> {
|
||||
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(
|
||||
'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();
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
export async function startDonation({
|
||||
@@ -169,6 +186,15 @@ export async function clearDonation(): Promise<void> {
|
||||
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
|
||||
|
||||
export async function _internalDoDonation({
|
||||
@@ -295,6 +321,13 @@ export async function _runDonationWorkflow(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
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`);
|
||||
updated = await _confirmPayment(existing);
|
||||
// continuing
|
||||
|
||||
@@ -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<{
|
||||
currencyType: string;
|
||||
paymentAmount: StripeDonationAmount;
|
||||
@@ -132,7 +153,7 @@ function submitDonation({
|
||||
> {
|
||||
return async (_dispatch, getState) => {
|
||||
if (!isStagingServer()) {
|
||||
log.error('internalAddDonationReceipt: Only available on staging server');
|
||||
log.error('submitDonation: Only available on staging server');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -191,6 +212,7 @@ export const actions = {
|
||||
clearWorkflow,
|
||||
internalAddDonationReceipt,
|
||||
setDidResume,
|
||||
resumeWorkflow,
|
||||
submitDonation,
|
||||
updateLastError,
|
||||
updateWorkflow,
|
||||
|
||||
@@ -43,7 +43,7 @@ export const SmartPreferencesDonations = memo(
|
||||
const theme = useSelector(getTheme);
|
||||
|
||||
const donationsState = useSelector((state: StateType) => state.donations);
|
||||
const { clearWorkflow, submitDonation, updateLastError } =
|
||||
const { clearWorkflow, resumeWorkflow, submitDonation, updateLastError } =
|
||||
useDonationsActions();
|
||||
|
||||
const ourNumber = useSelector(getUserNumber);
|
||||
@@ -93,9 +93,11 @@ export const SmartPreferencesDonations = memo(
|
||||
initialCurrency={initialCurrency}
|
||||
isStaging={isStaging}
|
||||
page={page}
|
||||
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
|
||||
lastError={donationsState.lastError}
|
||||
workflow={donationsState.currentWorkflow}
|
||||
clearWorkflow={clearWorkflow}
|
||||
resumeWorkflow={resumeWorkflow}
|
||||
updateLastError={updateLastError}
|
||||
submitDonation={submitDonation}
|
||||
setPage={setPage}
|
||||
|
||||
@@ -33,6 +33,7 @@ export enum ToastType {
|
||||
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
|
||||
DonationCancelled = 'DonationCancelled',
|
||||
DonationCompleted = 'DonationCompleted',
|
||||
DonationConfirmationNeeded = 'DonationConfirmationNeeded',
|
||||
DonationError = 'DonationError',
|
||||
DonationProcessing = 'DonationProcessing',
|
||||
DonationVerificationNeeded = 'DonationVerificationNeeded',
|
||||
@@ -128,6 +129,7 @@ export type AnyToast =
|
||||
| { toastType: ToastType.DeleteForEveryoneFailed }
|
||||
| { toastType: ToastType.DonationCancelled }
|
||||
| { toastType: ToastType.DonationCompleted }
|
||||
| { toastType: ToastType.DonationConfirmationNeeded }
|
||||
| { toastType: ToastType.DonationError }
|
||||
| { toastType: ToastType.DonationProcessing }
|
||||
| { toastType: ToastType.DonationVerificationFailed }
|
||||
|
||||
Reference in New Issue
Block a user