Donations: Introduce timeouts in early stages of the workflow

This commit is contained in:
Scott Nonnenberg 2025-07-31 07:15:59 +10:00 committed by GitHub
commit fd794ae90d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 198 additions and 109 deletions

View file

@ -5253,7 +5253,7 @@
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"icu:GroupV2--admin-approval-bounce--pluralized": { "icu:GroupV2--admin-approval-bounce--pluralized": {
"messageformat": "{joinerName} requested and cancelled {numberOfRequests, plural, one {their request} other {# requests}} to join via the group link", "messageformat": "{joinerName} requested and canceled {numberOfRequests, plural, one {their request} other {# requests}} to join via the group link",
"description": "Shown in timeline or conversation preview when v2 group changes" "description": "Shown in timeline or conversation preview when v2 group changes"
}, },
"icu:GroupV2--group-link-add--disabled--you": { "icu:GroupV2--group-link-add--disabled--you": {
@ -8902,9 +8902,9 @@
"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": { "icu:Donations__Toast__Canceled": {
"messageformat": "Donation cancelled", "messageformat": "Donation canceled",
"description": "Toast shown when a donation was manually cancelled by the user" "description": "Toast shown when a donation was manually canceled by the user"
}, },
"icu:Donations__Toast__Completed": { "icu:Donations__Toast__Completed": {
"messageformat": "Donation completed", "messageformat": "Donation completed",
@ -8978,6 +8978,14 @@
"messageformat": "Your donation is still being processed. This can take a few minutes.", "messageformat": "Your donation is still being processed. This can take a few minutes.",
"description": "An explanation for the 'still processing' dialog" "description": "An explanation for the 'still processing' dialog"
}, },
"icu:Donations__TimedOut": {
"messageformat": "Donation canceled",
"description": "Title of the dialog shown when the it's been too long since the last step in the donation process"
},
"icu:Donations__TimedOut__Description": {
"messageformat": "It has been too long since you started your donation. Please start over.",
"description": "An explanation for the 'donation timed out' dialog"
},
"icu:Donations__3dsValidationNeeded": { "icu:Donations__3dsValidationNeeded": {
"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"

View file

@ -159,7 +159,7 @@ async function safeDecryptToSink(
await Promise.race([ await Promise.race([
// Just use a non-existing event name to wait for an 'error'. We want // Just use a non-existing event name to wait for an 'error'. We want
// to handle errors on `sink` while generating digest in case the whole // to handle errors on `sink` while generating digest in case the whole
// request gets cancelled early. // request gets canceled early.
once(sink, 'non-error-event', { signal: controller.signal }), once(sink, 'non-error-event', { signal: controller.signal }),
decryptAttachmentV2ToSink(options, sink), decryptAttachmentV2ToSink(options, sink),
]); ]);

View file

@ -885,7 +885,7 @@ async function createWindow() {
); );
} }
if (!shouldClose) { if (!shouldClose) {
updater.onRestartCancelled(); updater.onRestartCanceled();
return; return;
} }

View file

@ -1189,7 +1189,7 @@ message GroupJoinRequestCanceledUpdate {
bytes requestorAci = 1; bytes requestorAci = 1;
} }
// A single requestor has requested to join and cancelled // A single requestor has requested to join and canceled
// their request repeatedly with no other updates in between. // their request repeatedly with no other updates in between.
// The last action encompassed by this update is always a // The last action encompassed by this update is always a
// cancellation; if there was another open request immediately // cancellation; if there was another open request immediately
@ -1392,4 +1392,4 @@ message ChatFolder {
repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self repeated uint64 includedRecipientIds = 7; // generated recipient id of groups, contacts, and/or note to self
repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self repeated uint64 excludedRecipientIds = 8; // generated recipient id of groups, contacts, and/or note to self
bytes id = 9; // should be 16 bytes bytes id = 9; // should be 16 bytes
} }

View file

@ -1517,7 +1517,7 @@ export async function startApp(): Promise<void> {
); );
if (!window.textsecure.storage.user.getAci()) { if (!window.textsecure.storage.user.getAci()) {
log.info( log.info(
"Expiration start timestamp cleanup: Cancelling update; we don't have our own UUID" "Expiration start timestamp cleanup: Canceling update; we don't have our own UUID"
); );
} else if (messagesUnexpectedlyMissingExpirationStartTimestamp.length) { } else if (messagesUnexpectedlyMissingExpirationStartTimestamp.length) {
const newMessageAttributes = const newMessageAttributes =

View file

@ -47,3 +47,12 @@ export function PaymentDeclined(): JSX.Element {
/> />
); );
} }
export function TimedOut(): JSX.Element {
return (
<DonationErrorModal
{...defaultProps}
errorType={donationErrorTypeSchema.Enum.TimedOut}
/>
);
}

View file

@ -40,6 +40,11 @@ export function DonationErrorModal(props: PropsType): JSX.Element {
body = i18n('icu:Donations__PaymentMethodDeclined__Description'); body = i18n('icu:Donations__PaymentMethodDeclined__Description');
break; break;
} }
case donationErrorTypeSchema.Enum.TimedOut: {
title = i18n('icu:Donations__TimedOut');
body = i18n('icu:Donations__TimedOut__Description');
break;
}
default: default:
throw missingCaseError(props.errorType); throw missingCaseError(props.errorType);

View file

@ -8,6 +8,7 @@ import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { PropsType } from './DonationVerificationModal'; import type { PropsType } from './DonationVerificationModal';
import { DonationVerificationModal } from './DonationVerificationModal'; import { DonationVerificationModal } from './DonationVerificationModal';
import { SECOND } from '../util/durations';
const { i18n } = window.SignalContext; const { i18n } = window.SignalContext;
@ -19,8 +20,13 @@ const defaultProps = {
i18n, i18n,
onCancelDonation: action('onCancelDonation'), onCancelDonation: action('onCancelDonation'),
onOpenBrowser: action('onOpenBrowser'), onOpenBrowser: action('onOpenBrowser'),
onTimedOut: action('onTimedOut'),
}; };
export function Default(): JSX.Element { export function Default(): JSX.Element {
return <DonationVerificationModal {...defaultProps} />; return <DonationVerificationModal {...defaultProps} />;
} }
export function FiveSecondTimeout(): JSX.Element {
return <DonationVerificationModal {...defaultProps} _timeout={5 * SECOND} />;
}

View file

@ -1,20 +1,24 @@
// 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, { useState } from 'react'; import React, { useEffect, useState } from 'react';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { Modal } from './Modal'; import { Modal } from './Modal';
import { Button, ButtonVariant } from './Button'; import { Button, ButtonVariant } from './Button';
import { DAY } from '../util/durations';
export type PropsType = { export type PropsType = {
// Test-only
_timeout?: number;
i18n: LocalizerType; i18n: LocalizerType;
onCancelDonation: () => unknown; onCancelDonation: () => unknown;
onOpenBrowser: () => unknown; onOpenBrowser: () => unknown;
onTimedOut: () => unknown;
}; };
export function DonationVerificationModal(props: PropsType): JSX.Element { export function DonationVerificationModal(props: PropsType): JSX.Element {
const { i18n, onCancelDonation, onOpenBrowser } = props; const { _timeout, i18n, onCancelDonation, onOpenBrowser, onTimedOut } = props;
const [hasOpenedBrowser, setHasOpenedBrowser] = useState(false); const [hasOpenedBrowser, setHasOpenedBrowser] = useState(false);
const titleText = hasOpenedBrowser const titleText = hasOpenedBrowser
@ -40,6 +44,19 @@ export function DonationVerificationModal(props: PropsType): JSX.Element {
</> </>
); );
useEffect(() => {
let timeout: NodeJS.Timeout | undefined = setTimeout(() => {
timeout = undefined;
onTimedOut();
}, _timeout ?? DAY);
return () => {
if (timeout) {
clearTimeout(timeout);
}
};
}, [_timeout, onTimedOut]);
return ( return (
<Modal <Modal
i18n={i18n} i18n={i18n}

View file

@ -683,8 +683,8 @@ BackupsPaidActive.args = {
}, },
}; };
export const BackupsPaidCancelled = Template.bind({}); export const BackupsPaidCanceled = Template.bind({});
BackupsPaidCancelled.args = { BackupsPaidCanceled.args = {
page: SettingsPage.Backups, page: SettingsPage.Backups,
backupFeatureEnabled: true, backupFeatureEnabled: true,
backupLocalBackupsEnabled: true, backupLocalBackupsEnabled: true,

View file

@ -18,7 +18,10 @@ import type {
OneTimeDonationHumanAmounts, OneTimeDonationHumanAmounts,
DonationErrorType, DonationErrorType,
} from '../types/Donations'; } from '../types/Donations';
import { donationStateSchema } from '../types/Donations'; import {
donationErrorTypeSchema,
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';
@ -544,7 +547,7 @@ export function PreferencesDonations({
onCancelDonation={() => { onCancelDonation={() => {
clearWorkflow(); clearWorkflow();
setPage(SettingsPage.Donations); setPage(SettingsPage.Donations);
showToast({ toastType: ToastType.DonationCancelled }); showToast({ toastType: ToastType.DonationCanceled });
}} }}
onRetryDonation={() => { onRetryDonation={() => {
resumeWorkflow(); resumeWorkflow();
@ -558,11 +561,16 @@ export function PreferencesDonations({
onCancelDonation={() => { onCancelDonation={() => {
clearWorkflow(); clearWorkflow();
setPage(SettingsPage.Donations); setPage(SettingsPage.Donations);
showToast({ toastType: ToastType.DonationCancelled }); showToast({ toastType: ToastType.DonationCanceled });
}} }}
onOpenBrowser={() => { onOpenBrowser={() => {
openLinkInWebBrowser(workflow.redirectTarget); openLinkInWebBrowser(workflow.redirectTarget);
}} }}
onTimedOut={() => {
clearWorkflow();
updateLastError(donationErrorTypeSchema.Enum.TimedOut);
setPage(SettingsPage.Donations);
}}
/> />
); );
} else if ( } else if (

View file

@ -100,8 +100,10 @@ function getToast(toastType: ToastType): AnyToast {
}; };
case ToastType.DeleteForEveryoneFailed: case ToastType.DeleteForEveryoneFailed:
return { toastType: ToastType.DeleteForEveryoneFailed }; return { toastType: ToastType.DeleteForEveryoneFailed };
case ToastType.DonationCancelled: case ToastType.DonationCanceled:
return { toastType: ToastType.DonationCancelled }; return { toastType: ToastType.DonationCanceled };
case ToastType.DonationCanceledWithView:
return { toastType: ToastType.DonationCanceledWithView };
case ToastType.DonationCompleted: case ToastType.DonationCompleted:
return { toastType: ToastType.DonationCompleted }; return { toastType: ToastType.DonationCompleted };
case ToastType.DonationConfirmationNeeded: case ToastType.DonationConfirmationNeeded:

View file

@ -280,10 +280,10 @@ export function renderToast({
); );
} }
if (toastType === ToastType.DonationCancelled) { if (toastType === ToastType.DonationCanceled) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>
{i18n('icu:Donations__Toast__Cancelled')} {i18n('icu:Donations__Toast__Canceled')}
</Toast> </Toast>
); );
} }
@ -337,12 +337,16 @@ export function renderToast({
} }
if ( if (
toastType === ToastType.DonationCanceledWithView ||
toastType === ToastType.DonationConfirmationNeeded || 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.DonationCanceledWithView]: i18n(
'icu:Donations__Toast__Canceled'
),
[ToastType.DonationConfirmationNeeded]: i18n( [ToastType.DonationConfirmationNeeded]: i18n(
'icu:Donations__Toast__ConfirmationNeeded' 'icu:Donations__Toast__ConfirmationNeeded'
), ),

View file

@ -478,7 +478,7 @@ async function runDownloadAttachmentJob({
} catch (error) { } catch (error) {
if (options.abortSignal.aborted) { if (options.abortSignal.aborted) {
log.info( log.info(
`${logId}: Cancelled attempt ${job.attempts}. Not scheduling a retry.` `${logId}: Canceled attempt ${job.attempts}. Not scheduling a retry. Error:`
); );
// Remove `pending` flag from the attachment. User can retry later. // Remove `pending` flag from the attachment. User can retry later.
await addAttachmentToMessage( await addAttachmentToMessage(

View file

@ -414,7 +414,7 @@ export abstract class JobManager<CoreJobType> {
abortController.abort(); abortController.abort();
// First tell those waiting for the job that it's not happening // First tell those waiting for the job that it's not happening
const rejectionError = new Error('Cancelled at JobManager.cancelJobs'); const rejectionError = new Error('Canceled at JobManager.cancelJobs');
const idWithAttempts = this.#getJobIdIncludingAttempts(job); const idWithAttempts = this.#getJobIdIncludingAttempts(job);
this.#jobCompletePromises.get(idWithAttempts)?.reject(rejectionError); this.#jobCompletePromises.get(idWithAttempts)?.reject(rejectionError);
this.#jobCompletePromises.delete(idWithAttempts); this.#jobCompletePromises.delete(idWithAttempts);
@ -438,7 +438,7 @@ export abstract class JobManager<CoreJobType> {
}) })
); );
log.warn(`${logId}: Successfully cancelled ${jobs.length} jobs`); log.warn(`${logId}: Successfully canceled ${jobs.length} jobs`);
} }
#addRunningJob( #addRunningJob(

View file

@ -868,7 +868,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
) { ) {
if (type === conversationQueueJobEnum.enum.ProfileKey) { if (type === conversationQueueJobEnum.enum.ProfileKey) {
log.warn( log.warn(
"Cancelling profile share, we don't want to wait for pending verification." "Canceling profile share, we don't want to wait for pending verification."
); );
return undefined; return undefined;
} }
@ -895,18 +895,16 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
if ( if (
verificationData.type === verificationData.type ===
ConversationVerificationState.VerificationCancelled ConversationVerificationState.VerificationCanceled
) { ) {
if (verificationData.canceledAt >= timestamp) { if (verificationData.canceledAt >= timestamp) {
log.info( log.info('canceling job; user canceled out of verification dialog.');
'cancelling job; user cancelled out of verification dialog.'
);
shouldContinue = false; shouldContinue = false;
} else { } else {
log.info( log.info(
'clearing cancellation tombstone; continuing ahead with job' 'clearing cancellation tombstone; continuing ahead with job'
); );
window.reduxActions.conversations.clearCancelledConversationVerification( window.reduxActions.conversations.clearCanceledConversationVerification(
conversation.id conversation.id
); );
} }
@ -984,7 +982,7 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
// Note: This should never happen, because the zod call in parseData wouldn't // Note: This should never happen, because the zod call in parseData wouldn't
// accept data that doesn't look like our type specification. // accept data that doesn't look like our type specification.
const problem: never = type; const problem: never = type;
log.error(`Got job with type ${problem}; Cancelling job.`); log.error(`Got job with type ${problem}; Canceling job.`);
} }
} }
@ -1056,14 +1054,14 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
if (untrustedServiceIds.length) { if (untrustedServiceIds.length) {
if (type === jobSet.ProfileKey || type === jobSet.ProfileKeyForCall) { if (type === jobSet.ProfileKey || type === jobSet.ProfileKeyForCall) {
log.warn( log.warn(
`Cancelling profile share, since there were ${untrustedServiceIds.length} untrusted send targets.` `Canceling profile share, since there were ${untrustedServiceIds.length} untrusted send targets.`
); );
return undefined; return undefined;
} }
if (type === jobSet.Receipts) { if (type === jobSet.Receipts) {
log.warn( log.warn(
`Cancelling receipt send, since there were ${untrustedServiceIds.length} untrusted send targets.` `Canceling receipt send, since there were ${untrustedServiceIds.length} untrusted send targets.`
); );
return undefined; return undefined;
} }

View file

@ -134,7 +134,7 @@ export async function sendCallingMessage(
error instanceof UnregisteredUserError error instanceof UnregisteredUserError
) { ) {
log.info( log.info(
`${logId}: Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Cancelling job.` `${logId}: Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Canceling job.`
); );
return; return;
} }

View file

@ -40,7 +40,7 @@ export async function sendDirectExpirationTimerUpdate(
if (!isDirectConversation(conversation.attributes)) { if (!isDirectConversation(conversation.attributes)) {
log.error( log.error(
`Conversation ${conversation.idForLogging()} is not a 1:1 conversation; cancelling expiration timer job.` `Conversation ${conversation.idForLogging()} is not a 1:1 conversation; canceling expiration timer job.`
); );
return; return;
} }
@ -91,7 +91,7 @@ export async function sendDirectExpirationTimerUpdate(
if (!proto.dataMessage) { if (!proto.dataMessage) {
log.error( log.error(
"ContentMessage proto didn't have a data message; cancelling job." "ContentMessage proto didn't have a data message; canceling job."
); );
return; return;
} }

View file

@ -142,7 +142,7 @@ export async function sendNullMessage(
error instanceof UnregisteredUserError error instanceof UnregisteredUserError
) { ) {
log.info( log.info(
'Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Cancelling job.' 'Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Canceling job.'
); );
return; return;
} }

View file

@ -79,13 +79,13 @@ export async function sendProfileKey(
} }
if (!data?.isOneTimeSend && !conversation.get('profileSharing')) { if (!data?.isOneTimeSend && !conversation.get('profileSharing')) {
log.info('No longer sharing profile. Cancelling job.'); log.info('No longer sharing profile. Canceling job.');
return; return;
} }
const profileKey = await ourProfileKeyService.get(); const profileKey = await ourProfileKeyService.get();
if (!profileKey) { if (!profileKey) {
log.info('Unable to fetch profile. Cancelling job.'); log.info('Unable to fetch profile. Canceling job.');
return; return;
} }

View file

@ -76,12 +76,12 @@ export async function sendResendRequest(
); );
if (!isDirectConversation(conversation.attributes)) { if (!isDirectConversation(conversation.attributes)) {
log.error('conversation is not direct, cancelling job.'); log.error('conversation is not direct, canceling job.');
return; return;
} }
if (isConversationUnregistered(conversation.attributes)) { if (isConversationUnregistered(conversation.attributes)) {
log.error('conversation is unregistered, cancelling job.'); log.error('conversation is unregistered, canceling job.');
failoverToLocalReset(log, data); failoverToLocalReset(log, data);
return; return;
} }
@ -90,7 +90,7 @@ export async function sendResendRequest(
// Any needed blocking should still apply once the decryption error is fixed. // Any needed blocking should still apply once the decryption error is fixed.
if (conversation.getAci() !== senderAci) { if (conversation.getAci() !== senderAci) {
log.error('conversation was missing a aci, cancelling job.'); log.error('conversation was missing a aci, canceling job.');
failoverToLocalReset(log, data); failoverToLocalReset(log, data);
return; return;
} }
@ -171,7 +171,7 @@ export async function sendResendRequest(
error instanceof UnregisteredUserError error instanceof UnregisteredUserError
) { ) {
log.info( log.info(
'Group send failures were all OutgoingIdentityKeyError or UnregisteredUserError. Cancelling job.' 'Group send failures were all OutgoingIdentityKeyError or UnregisteredUserError. Canceling job.'
); );
return; return;

View file

@ -61,7 +61,7 @@ export async function sendSavedProto(
const serviceId = conversation.getServiceId(); const serviceId = conversation.getServiceId();
if (!serviceId) { if (!serviceId) {
log.info( log.info(
`conversation ${conversation.idForLogging()} was missing serviceId, cancelling job.` `conversation ${conversation.idForLogging()} was missing serviceId, canceling job.`
); );
return; return;
} }
@ -101,7 +101,7 @@ export async function sendSavedProto(
error instanceof UnregisteredUserError error instanceof UnregisteredUserError
) { ) {
log.info( log.info(
'Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Cancelling job.' 'Send failure was OutgoingIdentityKeyError or UnregisteredUserError. Canceling job.'
); );
return; return;
} }

View file

@ -73,14 +73,14 @@ export async function sendSenderKeyDistribution(
if (!distributionId) { if (!distributionId) {
log.info( log.info(
`group ${group?.idForLogging()} had no distributionid, cancelling job.` `group ${group?.idForLogging()} had no distributionid, canceling job.`
); );
return; return;
} }
if (!serviceId) { if (!serviceId) {
log.info( log.info(
`conversation ${conversation.idForLogging()} was missing serviceId, cancelling job.` `conversation ${conversation.idForLogging()} was missing serviceId, canceling job.`
); );
return; return;
} }
@ -106,7 +106,7 @@ export async function sendSenderKeyDistribution(
error instanceof UnregisteredUserError error instanceof UnregisteredUserError
) { ) {
log.info( log.info(
'Send failure was NoSenderKeyError, OutgoingIdentityKeyError or UnregisteredUserError. Cancelling job.' 'Send failure was NoSenderKeyError, OutgoingIdentityKeyError or UnregisteredUserError. Canceling job.'
); );
return; return;
} }

View file

@ -87,6 +87,21 @@ export async function initialize(): Promise<void> {
const shouldShowToast = const shouldShowToast =
didResumeWorkflowAtStartup() && !isDonationPageVisible(); didResumeWorkflowAtStartup() && !isDonationPageVisible();
const isTooOld = isOlderThan(workflow.timestamp, DAY);
if (
isTooOld &&
(workflow.type === donationStateSchema.Enum.INTENT_METHOD ||
workflow.type === donationStateSchema.Enum.INTENT_REDIRECT)
) {
log.info(
`initialize: Workflow at ${workflow.type} is too old, canceling donation.`
);
await clearDonation();
await failDonation(donationErrorTypeSchema.Enum.TimedOut);
return;
}
if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) { if (workflow.type === donationStateSchema.Enum.INTENT_METHOD) {
if (shouldShowToast) { if (shouldShowToast) {
@ -783,6 +798,13 @@ async function failDonation(errorType: DonationErrorType): Promise<void> {
window.reduxActions.toast.showToast({ window.reduxActions.toast.showToast({
toastType: ToastType.DonationVerificationFailed, toastType: ToastType.DonationVerificationFailed,
}); });
} else if (errorType === donationErrorTypeSchema.Enum.TimedOut) {
log.info(
`${logId}: Donation page not visible. Showing 'donation canceled w/view' toast.`
);
window.reduxActions.toast.showToast({
toastType: ToastType.DonationCanceledWithView,
});
} else { } else {
log.info( log.info(
`${logId}: Donation page not visible. Showing 'error processing donation' toast.` `${logId}: Donation page not visible. Showing 'error processing donation' toast.`
@ -891,7 +913,8 @@ function isDonationPageVisible() {
const { selectedLocation } = window.reduxStore.getState().nav; const { selectedLocation } = window.reduxStore.getState().nav;
return ( return (
selectedLocation.tab === NavTab.Settings && selectedLocation.tab === NavTab.Settings &&
(selectedLocation.details.page === SettingsPage.DonationsDonateFlow || (selectedLocation.details.page === SettingsPage.Donations ||
selectedLocation.details.page === SettingsPage.DonationsDonateFlow ||
selectedLocation.details.page === SettingsPage.DonationsReceiptList) selectedLocation.details.page === SettingsPage.DonationsReceiptList)
); );
} }

View file

@ -200,9 +200,7 @@ export class ProfileService {
this.#jobsByConversationId.forEach(job => { this.#jobsByConversationId.forEach(job => {
job.reject( job.reject(
new Error( new Error(`ProfileService.clearAll: job canceled because '${reason}'`)
`ProfileService.clearAll: job cancelled because '${reason}'`
)
); );
}); });

View file

@ -483,12 +483,12 @@ export function toAccountRecord(
if (typeof subscriberCurrencyCode === 'string') { if (typeof subscriberCurrencyCode === 'string') {
accountRecord.donorSubscriberCurrencyCode = subscriberCurrencyCode; accountRecord.donorSubscriberCurrencyCode = subscriberCurrencyCode;
} }
const donorSubscriptionManuallyCancelled = window.storage.get( const donorSubscriptionManuallyCanceled = window.storage.get(
'donorSubscriptionManuallyCancelled' 'donorSubscriptionManuallyCancelled'
); );
if (typeof donorSubscriptionManuallyCancelled === 'boolean') { if (typeof donorSubscriptionManuallyCanceled === 'boolean') {
accountRecord.donorSubscriptionManuallyCancelled = accountRecord.donorSubscriptionManuallyCancelled =
donorSubscriptionManuallyCancelled; donorSubscriptionManuallyCanceled;
} }
accountRecord.backupSubscriberData = generateBackupsSubscriberData(); accountRecord.backupSubscriberData = generateBackupsSubscriberData();

View file

@ -506,7 +506,7 @@ export type ConversationVerificationData = ReadonlyDeep<
>; >;
} }
| { | {
type: ConversationVerificationState.VerificationCancelled; type: ConversationVerificationState.VerificationCanceled;
canceledAt: number; canceledAt: number;
} }
>; >;
@ -618,8 +618,7 @@ export const getConversationCallMode = (
const CANCEL_CONVERSATION_PENDING_VERIFICATION = const CANCEL_CONVERSATION_PENDING_VERIFICATION =
'conversations/CANCEL_CONVERSATION_PENDING_VERIFICATION'; 'conversations/CANCEL_CONVERSATION_PENDING_VERIFICATION';
const CLEAR_CANCELLED_VERIFICATION = const CLEAR_CANCELED_VERIFICATION = 'conversations/CLEAR_CANCELED_VERIFICATION';
'conversations/CLEAR_CANCELLED_VERIFICATION';
const CLEAR_CONVERSATIONS_PENDING_VERIFICATION = const CLEAR_CONVERSATIONS_PENDING_VERIFICATION =
'conversations/CLEAR_CONVERSATIONS_PENDING_VERIFICATION'; 'conversations/CLEAR_CONVERSATIONS_PENDING_VERIFICATION';
export const COLORS_CHANGED = 'conversations/COLORS_CHANGED'; export const COLORS_CHANGED = 'conversations/COLORS_CHANGED';
@ -668,8 +667,8 @@ type ClearInvitedServiceIdsForNewlyCreatedGroupActionType = ReadonlyDeep<{
type ClearVerificationDataByConversationActionType = ReadonlyDeep<{ type ClearVerificationDataByConversationActionType = ReadonlyDeep<{
type: typeof CLEAR_CONVERSATIONS_PENDING_VERIFICATION; type: typeof CLEAR_CONVERSATIONS_PENDING_VERIFICATION;
}>; }>;
type ClearCancelledVerificationActionType = ReadonlyDeep<{ type ClearCanceledVerificationActionType = ReadonlyDeep<{
type: typeof CLEAR_CANCELLED_VERIFICATION; type: typeof CLEAR_CANCELED_VERIFICATION;
payload: { payload: {
conversationId: string; conversationId: string;
}; };
@ -1048,7 +1047,7 @@ export type ConsumePreloadDataActionType = ReadonlyDeep<{
export type ConversationActionType = export type ConversationActionType =
| AddPreloadDataActionType | AddPreloadDataActionType
| CancelVerificationDataByConversationActionType | CancelVerificationDataByConversationActionType
| ClearCancelledVerificationActionType | ClearCanceledVerificationActionType
| ClearGroupCreationErrorActionType | ClearGroupCreationErrorActionType
| ClearInvitedServiceIdsForNewlyCreatedGroupActionType | ClearInvitedServiceIdsForNewlyCreatedGroupActionType
| ClearTargetedMessageActionType | ClearTargetedMessageActionType
@ -1134,7 +1133,7 @@ export const actions = {
cancelAttachmentDownload, cancelAttachmentDownload,
cancelConversationVerification, cancelConversationVerification,
changeHasGroupLink, changeHasGroupLink,
clearCancelledConversationVerification, clearCanceledConversationVerification,
clearGroupCreationError, clearGroupCreationError,
clearInvitedServiceIdsForNewlyCreatedGroup, clearInvitedServiceIdsForNewlyCreatedGroup,
clearTargetedMessage, clearTargetedMessage,
@ -2783,11 +2782,11 @@ function verifyConversationsStoppingSend(): ThunkAction<
}; };
} }
export function clearCancelledConversationVerification( export function clearCanceledConversationVerification(
conversationId: string conversationId: string
): ClearCancelledVerificationActionType { ): ClearCanceledVerificationActionType {
return { return {
type: CLEAR_CANCELLED_VERIFICATION, type: CLEAR_CANCELED_VERIFICATION,
payload: { payload: {
conversationId, conversationId,
}, },
@ -5229,7 +5228,7 @@ function getVerificationDataForConversation({
if ( if (
!existing || !existing ||
existing.type === ConversationVerificationState.VerificationCancelled existing.type === ConversationVerificationState.VerificationCanceled
) { ) {
return { return {
[conversationId]: { [conversationId]: {
@ -5527,7 +5526,7 @@ export function reducer(
}; };
} }
if (action.type === CLEAR_CANCELLED_VERIFICATION) { if (action.type === CLEAR_CANCELED_VERIFICATION) {
const { conversationId } = action.payload; const { conversationId } = action.payload;
const { verificationDataByConversation } = state; const { verificationDataByConversation } = state;
@ -5568,13 +5567,13 @@ export function reducer(
for (const [conversationId, data] of entries) { for (const [conversationId, data] of entries) {
if ( if (
data.type === ConversationVerificationState.VerificationCancelled && data.type === ConversationVerificationState.VerificationCanceled &&
data.canceledAt > canceledAt data.canceledAt > canceledAt
) { ) {
newverificationDataByConversation[conversationId] = data; newverificationDataByConversation[conversationId] = data;
} else { } else {
newverificationDataByConversation[conversationId] = { newverificationDataByConversation[conversationId] = {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt, canceledAt,
}; };
} }

View file

@ -23,7 +23,7 @@ export enum OneTimeModalState {
export enum ConversationVerificationState { export enum ConversationVerificationState {
PendingVerification = 'PendingVerification', PendingVerification = 'PendingVerification',
VerificationCancelled = 'VerificationCancelled', VerificationCanceled = 'VerificationCanceled',
} }
export enum TargetedMessageSource { export enum TargetedMessageSource {

View file

@ -261,8 +261,15 @@ export function reducer(
if (action.type === UPDATE_WORKFLOW) { if (action.type === UPDATE_WORKFLOW) {
const { nextWorkflow } = action.payload; const { nextWorkflow } = action.payload;
// If we've cleared the workflow or are starting afresh, we clear the startup flag
const didResumeWorkflowAtStartup =
!nextWorkflow || nextWorkflow.type === donationStateSchema.Enum.INTENT
? false
: state.didResumeWorkflowAtStartup;
return { return {
...state, ...state,
didResumeWorkflowAtStartup,
currentWorkflow: nextWorkflow, currentWorkflow: nextWorkflow,
}; };
} }

View file

@ -61,7 +61,7 @@ export function changeLocation(
}); });
if (needToCancel) { if (needToCancel) {
log.info(`${logId}: Cancelling navigation`); log.info(`${logId}: Canceling navigation`);
return; return;
} }

View file

@ -54,10 +54,10 @@ describe('util/profiles', () => {
service.clearAll('testing'); service.clearAll('testing');
await assert.isRejected(promise1, 'job cancelled'); await assert.isRejected(promise1, 'job canceled');
await assert.isRejected(promise2, 'job cancelled'); await assert.isRejected(promise2, 'job canceled');
await assert.isRejected(promise3, 'job cancelled'); await assert.isRejected(promise3, 'job canceled');
await assert.isRejected(promise4, 'job cancelled'); await assert.isRejected(promise4, 'job canceled');
}); });
}); });
@ -147,9 +147,9 @@ describe('util/profiles', () => {
// Never queued // Never queued
const promise5 = service.get(SERVICE_ID_5, null); const promise5 = service.get(SERVICE_ID_5, null);
await assert.isRejected(promise2, 'job cancelled'); await assert.isRejected(promise2, 'job canceled');
await assert.isRejected(promise3, 'job cancelled'); await assert.isRejected(promise3, 'job canceled');
await assert.isRejected(promise4, 'job cancelled'); await assert.isRejected(promise4, 'job canceled');
await assert.isRejected(promise5, 'paused queue'); await assert.isRejected(promise5, 'paused queue');
assert.strictEqual(runCount, 3, 'after await'); assert.strictEqual(runCount, 3, 'after await');
@ -184,9 +184,9 @@ describe('util/profiles', () => {
// Queued, because we aren't pausing // Queued, because we aren't pausing
const promise5 = service.get(SERVICE_ID_5, null); const promise5 = service.get(SERVICE_ID_5, null);
await assert.isRejected(promise2, 'job cancelled'); await assert.isRejected(promise2, 'job canceled');
await assert.isRejected(promise3, 'job cancelled'); await assert.isRejected(promise3, 'job canceled');
await assert.isRejected(promise4, 'job cancelled'); await assert.isRejected(promise4, 'job canceled');
// It didn't succeed, but we log and resolve as normal // It didn't succeed, but we log and resolve as normal
await assert.isFulfilled(promise5); await assert.isFulfilled(promise5);

View file

@ -29,7 +29,7 @@ import {
TARGETED_CONVERSATION_CHANGED, TARGETED_CONVERSATION_CHANGED,
actions, actions,
cancelConversationVerification, cancelConversationVerification,
clearCancelledConversationVerification, clearCanceledConversationVerification,
getConversationCallMode, getConversationCallMode,
getEmptyState, getEmptyState,
reducer, reducer,
@ -940,12 +940,12 @@ describe('both/state/ducks/conversations', () => {
}); });
}); });
it('stomps on VerificationCancelled state', () => { it('stomps on VerificationCanceled state', () => {
const state: ConversationsStateType = { const state: ConversationsStateType = {
...getEmptyState(), ...getEmptyState(),
verificationDataByConversation: { verificationDataByConversation: {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: Date.now(), canceledAt: Date.now(),
}, },
}, },
@ -1111,19 +1111,19 @@ describe('both/state/ducks/conversations', () => {
assert.deepStrictEqual(actual.verificationDataByConversation, { assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now, canceledAt: now,
}, },
}); });
}); });
it('updates timestamp for existing VerificationCancelled state', () => { it('updates timestamp for existing VerificationCanceled state', () => {
const now = Date.now(); const now = Date.now();
const state: ConversationsStateType = { const state: ConversationsStateType = {
...getEmptyState(), ...getEmptyState(),
verificationDataByConversation: { verificationDataByConversation: {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now - 1, canceledAt: now - 1,
}, },
}, },
@ -1133,19 +1133,19 @@ describe('both/state/ducks/conversations', () => {
assert.deepStrictEqual(actual.verificationDataByConversation, { assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now, canceledAt: now,
}, },
}); });
}); });
it('uses newest timestamp when updating existing VerificationCancelled state', () => { it('uses newest timestamp when updating existing VerificationCanceled state', () => {
const now = Date.now(); const now = Date.now();
const state: ConversationsStateType = { const state: ConversationsStateType = {
...getEmptyState(), ...getEmptyState(),
verificationDataByConversation: { verificationDataByConversation: {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now, canceledAt: now,
}, },
}, },
@ -1155,7 +1155,7 @@ describe('both/state/ducks/conversations', () => {
assert.deepStrictEqual(actual.verificationDataByConversation, { assert.deepStrictEqual(actual.verificationDataByConversation, {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now, canceledAt: now,
}, },
}); });
@ -1171,20 +1171,20 @@ describe('both/state/ducks/conversations', () => {
}); });
describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => { describe('CANCEL_CONVERSATION_PENDING_VERIFICATION', () => {
it('removes existing VerificationCancelled state', () => { it('removes existing VerificationCanceled state', () => {
const now = Date.now(); const now = Date.now();
const state: ConversationsStateType = { const state: ConversationsStateType = {
...getEmptyState(), ...getEmptyState(),
verificationDataByConversation: { verificationDataByConversation: {
'convo A': { 'convo A': {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: now, canceledAt: now,
}, },
}, },
}; };
const actual = reducer( const actual = reducer(
state, state,
clearCancelledConversationVerification('convo A') clearCanceledConversationVerification('convo A')
); );
assert.deepStrictEqual(actual.verificationDataByConversation, {}); assert.deepStrictEqual(actual.verificationDataByConversation, {});
@ -1202,7 +1202,7 @@ describe('both/state/ducks/conversations', () => {
}; };
const actual = reducer( const actual = reducer(
state, state,
clearCancelledConversationVerification('convo A') clearCanceledConversationVerification('convo A')
); );
assert.deepStrictEqual(actual, state); assert.deepStrictEqual(actual, state);
@ -1212,7 +1212,7 @@ describe('both/state/ducks/conversations', () => {
const state: ConversationsStateType = getEmptyState(); const state: ConversationsStateType = getEmptyState();
const actual = reducer( const actual = reducer(
state, state,
clearCancelledConversationVerification('convo A') clearCanceledConversationVerification('convo A')
); );
assert.deepStrictEqual(actual, state); assert.deepStrictEqual(actual, state);

View file

@ -234,11 +234,11 @@ describe('both/state/selectors/conversations-extra', () => {
...state.conversations, ...state.conversations,
verificationDataByConversation: { verificationDataByConversation: {
direct1: { direct1: {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: Date.now(), canceledAt: Date.now(),
}, },
direct2: { direct2: {
type: ConversationVerificationState.VerificationCancelled, type: ConversationVerificationState.VerificationCanceled,
canceledAt: Date.now(), canceledAt: Date.now(),
}, },
}, },

View file

@ -424,7 +424,7 @@ describe('backups', function (this: Mocha.Suite) {
await window.locator('.module-message >> "Message 33"').waitFor(); await window.locator('.module-message >> "Message 33"').waitFor();
}); });
it('handles remote ephemeral backup cancelation', async function () { it('handles remote ephemeral backup cancellation', async function () {
const ephemeralBackupKey = randomBytes(32); const ephemeralBackupKey = randomBytes(32);
const { phone, server } = bootstrap; const { phone, server } = bootstrap;

View file

@ -282,7 +282,7 @@ export class SocketManager extends EventListener {
try { try {
await sleep(timeout, reconnectController.signal); await sleep(timeout, reconnectController.signal);
} catch { } catch {
log.info('reconnect cancelled'); log.info('reconnect canceled');
return; return;
} finally { } finally {
if (this.#reconnectController === reconnectController) { if (this.#reconnectController === reconnectController) {

View file

@ -2108,10 +2108,10 @@ export function initialize({
} }
function cancelInflightRequests(reason: string) { function cancelInflightRequests(reason: string) {
const logId = `cancelInflightRequests/${reason}`; const logId = `cancelInflightRequests/${reason}`;
log.warn(`${logId}: Cancelling ${inflightRequests.size} requests`); log.warn(`${logId}: Canceling ${inflightRequests.size} requests`);
for (const request of inflightRequests) { for (const request of inflightRequests) {
try { try {
request(new Error(`${logId}: Cancelled!`)); request(new Error(`${logId}: Canceled!`));
} catch (error: unknown) { } catch (error: unknown) {
log.error( log.error(
`${logId}: Failed to cancel request: ${toLogFormat(error)}` `${logId}: Failed to cancel request: ${toLogFormat(error)}`

View file

@ -21,6 +21,8 @@ export const donationErrorTypeSchema = z.enum([
'GeneralError', 'GeneralError',
// Any 4xx error when adding payment method or confirming intent // Any 4xx error when adding payment method or confirming intent
'PaymentDeclined', 'PaymentDeclined',
// When it's been too long since the last step of the donation, and card wasn't charged
'TimedOut',
]); ]);
export type DonationErrorType = z.infer<typeof donationErrorTypeSchema>; export type DonationErrorType = z.infer<typeof donationErrorTypeSchema>;

View file

@ -180,6 +180,7 @@ export type StorageAccessType = {
areWeASubscriber: boolean; areWeASubscriber: boolean;
subscriberId: Uint8Array; subscriberId: Uint8Array;
subscriberCurrencyCode: string; subscriberCurrencyCode: string;
// Note: for historical reasons, this has two l's
donorSubscriptionManuallyCancelled: boolean; donorSubscriptionManuallyCancelled: boolean;
backupsSubscriberId: Uint8Array; backupsSubscriberId: Uint8Array;
backupsSubscriberPurchaseToken: string; backupsSubscriberPurchaseToken: string;

View file

@ -31,7 +31,8 @@ export enum ToastType {
DecryptionError = 'DecryptionError', DecryptionError = 'DecryptionError',
DebugLogError = 'DebugLogError', DebugLogError = 'DebugLogError',
DeleteForEveryoneFailed = 'DeleteForEveryoneFailed', DeleteForEveryoneFailed = 'DeleteForEveryoneFailed',
DonationCancelled = 'DonationCancelled', DonationCanceled = 'DonationCanceled',
DonationCanceledWithView = 'DonationCanceledWithView',
DonationCompleted = 'DonationCompleted', DonationCompleted = 'DonationCompleted',
DonationConfirmationNeeded = 'DonationConfirmationNeeded', DonationConfirmationNeeded = 'DonationConfirmationNeeded',
DonationError = 'DonationError', DonationError = 'DonationError',
@ -127,7 +128,8 @@ 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.DonationCanceled }
| { toastType: ToastType.DonationCanceledWithView }
| { toastType: ToastType.DonationCompleted } | { toastType: ToastType.DonationCompleted }
| { toastType: ToastType.DonationConfirmationNeeded } | { toastType: ToastType.DonationConfirmationNeeded }
| { toastType: ToastType.DonationError } | { toastType: ToastType.DonationError }

View file

@ -192,15 +192,15 @@ export abstract class Updater {
return this.#checkForUpdatesMaybeInstall(CheckType.ForceDownload); return this.#checkForUpdatesMaybeInstall(CheckType.ForceDownload);
} }
// If the updater was about to restart the app but the user cancelled it, show dialog // If the updater was about to restart the app but the user canceled it, show dialog
// to let them retry the restart // to let them retry the restart
public onRestartCancelled(): void { public onRestartCanceled(): void {
if (!this.#restarting) { if (!this.#restarting) {
return; return;
} }
this.logger.info( this.logger.info(
'updater/onRestartCancelled: restart was cancelled. forcing update to reset updater state' 'updater/onRestartCanceled: restart was canceled. forcing update to reset updater state'
); );
this.#restarting = false; this.#restarting = false;
markShouldNotQuit(); markShouldNotQuit();

View file

@ -56,9 +56,9 @@ export async function force(): Promise<void> {
} }
} }
export function onRestartCancelled(): void { export function onRestartCanceled(): void {
if (updater) { if (updater) {
updater.onRestartCancelled(); updater.onRestartCanceled();
} }
} }

View file

@ -291,7 +291,7 @@ export function createIPCEvents(
); );
return true; return true;
} catch { } catch {
log.info('requestCloseConfirmation: Close cancelled by user.'); log.info('requestCloseConfirmation: Close canceled by user.');
return false; return false;
} }
}, },