mirror of
https://github.com/signalapp/Signal-Desktop.git
synced 2025-12-20 02:08:57 +00:00
Post Donate Badge Toggle Modal
This commit is contained in:
@@ -9022,6 +9022,10 @@
|
||||
"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__Toast__DonationCompletedAndBadgeApplicationFailed": {
|
||||
"messageformat": "Donation succeeded, but we could not update your badge settings",
|
||||
"description": "Toast shown when donation completes successfully but badge application fails"
|
||||
},
|
||||
"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"
|
||||
@@ -9106,6 +9110,22 @@
|
||||
"messageformat": "Cancel donation",
|
||||
"description": "When external payment method validation is required, this button will open that external verification website"
|
||||
},
|
||||
"icu:Donations__badge-modal--title": {
|
||||
"messageformat": "Thanks for your support!",
|
||||
"description": "Title shown in the 'thanks for your donation' modal"
|
||||
},
|
||||
"icu:Donations__badge-modal--description": {
|
||||
"messageformat": "You've earned a donor badge from Signal! Display it on your profile to show off your support.",
|
||||
"description": "Description explaining the donor badge in the 'thanks for your donation' modal"
|
||||
},
|
||||
"icu:Donations__badge-modal--display-on-profile": {
|
||||
"messageformat": "Display on profile",
|
||||
"description": "Toggle label for displaying the donation badge on user's profile in the 'thanks for your donation' modal"
|
||||
},
|
||||
"icu:Donations__badge-modal--help-text": {
|
||||
"messageformat": "You can adjust this on your mobile device under Settings → Donate to Signal → Badges",
|
||||
"description": "Help text below the toggle in donation thank you modal"
|
||||
},
|
||||
"icu:WhatsNew__bugfixes": {
|
||||
"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",
|
||||
|
||||
79
stylesheets/components/DonationThanksModal.scss
Normal file
79
stylesheets/components/DonationThanksModal.scss
Normal file
@@ -0,0 +1,79 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
@use '../mixins';
|
||||
@use '../variables';
|
||||
|
||||
.DonationThanksModal {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
|
||||
&__badge-icon {
|
||||
width: 88px;
|
||||
height: 88px;
|
||||
margin-bottom: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&__content {
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
&__title {
|
||||
@include mixins.font-title-medium;
|
||||
margin-bottom: 16px;
|
||||
color: light-dark(
|
||||
variables.$color-black-alpha-85,
|
||||
variables.$color-white-alpha-85
|
||||
);
|
||||
}
|
||||
|
||||
&__description {
|
||||
@include mixins.font-body-2;
|
||||
max-width: 308px;
|
||||
color: light-dark(variables.$color-gray-90, variables.$color-gray-05);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
&__separator {
|
||||
width: 100%;
|
||||
height: 0.5px;
|
||||
border: none;
|
||||
margin-block: 0 24px;
|
||||
margin-inline: 0;
|
||||
background-color: light-dark(
|
||||
variables.$color-black-alpha-12,
|
||||
variables.$color-white-alpha-12
|
||||
);
|
||||
}
|
||||
|
||||
&__toggle-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding-block: 0;
|
||||
}
|
||||
|
||||
&__toggle-text {
|
||||
@include mixins.font-body-1;
|
||||
color: light-dark(
|
||||
variables.$color-black-alpha-85,
|
||||
variables.$color-white-alpha-85
|
||||
);
|
||||
}
|
||||
|
||||
&__help-text {
|
||||
@include mixins.font-caption;
|
||||
text-align: start;
|
||||
margin-top: 12px;
|
||||
color: light-dark(
|
||||
variables.$color-black-alpha-50,
|
||||
variables.$color-white-alpha-50
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -95,6 +95,7 @@
|
||||
@use 'components/DebugLogWindow.scss';
|
||||
@use 'components/DeleteMessagesModal.scss';
|
||||
@use 'components/DisappearingTimeDialog.scss';
|
||||
@use 'components/DonationThanksModal.scss';
|
||||
@use 'components/DonationErrorModal.scss';
|
||||
@use 'components/DonationForm.scss';
|
||||
@use 'components/DonationInterruptedModal.scss';
|
||||
|
||||
50
ts/components/DonationThanksModal.stories.tsx
Normal file
50
ts/components/DonationThanksModal.stories.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
// 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, StoryFn } from '@storybook/react';
|
||||
|
||||
import type { PropsType } from './DonationThanksModal';
|
||||
import { DonationThanksModal } from './DonationThanksModal';
|
||||
import { getFakeBadge } from '../test-helpers/getFakeBadge';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
export default {
|
||||
title: 'Components/DonationThanksModal',
|
||||
component: DonationThanksModal,
|
||||
} satisfies Meta<PropsType>;
|
||||
|
||||
const donationBadge = getFakeBadge({ id: 'donation-badge' });
|
||||
|
||||
const defaultProps = {
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
badge: donationBadge,
|
||||
applyDonationBadge: ({
|
||||
onComplete,
|
||||
}: {
|
||||
badge: unknown;
|
||||
applyBadge: boolean;
|
||||
onComplete: (error?: Error) => void;
|
||||
}) => {
|
||||
action('applyDonationBadge')();
|
||||
// Simulate async badge application
|
||||
setTimeout(() => {
|
||||
onComplete();
|
||||
}, 500);
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/function-component-definition
|
||||
const Template: StoryFn<PropsType> = args => <DonationThanksModal {...args} />;
|
||||
|
||||
export const Default = Template.bind({});
|
||||
Default.args = defaultProps;
|
||||
|
||||
export const LoadingBadge = Template.bind({});
|
||||
LoadingBadge.args = {
|
||||
...defaultProps,
|
||||
badge: undefined,
|
||||
};
|
||||
115
ts/components/DonationThanksModal.tsx
Normal file
115
ts/components/DonationThanksModal.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type { BadgeType } from '../badges/types';
|
||||
import { Modal } from './Modal';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { BadgeImage } from './BadgeImage';
|
||||
import { Spinner } from './Spinner';
|
||||
|
||||
export type PropsType = {
|
||||
i18n: LocalizerType;
|
||||
onClose: (error?: Error) => void;
|
||||
badge: BadgeType | undefined;
|
||||
applyDonationBadge: (args: {
|
||||
badge: BadgeType | undefined;
|
||||
applyBadge: boolean;
|
||||
onComplete: (error?: Error) => void;
|
||||
}) => void;
|
||||
};
|
||||
|
||||
export function DonationThanksModal({
|
||||
i18n,
|
||||
onClose,
|
||||
badge,
|
||||
applyDonationBadge,
|
||||
}: PropsType): JSX.Element {
|
||||
const [applyBadgeIsChecked, setApplyBadgeIsChecked] = useState(true);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
const handleToggleBadge = (enabled: boolean) => {
|
||||
setApplyBadgeIsChecked(enabled);
|
||||
};
|
||||
|
||||
const handleDone = () => {
|
||||
if (isUpdating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUpdating(true);
|
||||
|
||||
applyDonationBadge({
|
||||
badge,
|
||||
applyBadge: applyBadgeIsChecked,
|
||||
onComplete: (error?: Error) => {
|
||||
setIsUpdating(false);
|
||||
onClose(error);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalName="DonationThanksModal"
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
hasXButton
|
||||
noMouseClose
|
||||
modalFooter={
|
||||
<Button
|
||||
variant={ButtonVariant.Primary}
|
||||
onClick={handleDone}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
{i18n('icu:done')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<div className="DonationThanksModal">
|
||||
<div className="DonationThanksModal__badge-icon">
|
||||
{badge ? (
|
||||
<BadgeImage badge={badge} size={88} />
|
||||
) : (
|
||||
<Spinner
|
||||
ariaLabel={i18n('icu:loading')}
|
||||
moduleClassName="BadgeImage BadgeImage__loading"
|
||||
size="88px"
|
||||
svgSize="normal"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="DonationThanksModal__content">
|
||||
<h2 className="DonationThanksModal__title">
|
||||
{i18n('icu:Donations__badge-modal--title')}
|
||||
</h2>
|
||||
<p className="DonationThanksModal__description">
|
||||
{i18n('icu:Donations__badge-modal--description')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="DonationThanksModal__separator" />
|
||||
|
||||
<div className="DonationThanksModal__toggle-section">
|
||||
<Checkbox
|
||||
checked={applyBadgeIsChecked}
|
||||
label=""
|
||||
name="donation-badge-display"
|
||||
onChange={handleToggleBadge}
|
||||
/>
|
||||
<span className="DonationThanksModal__toggle-text">
|
||||
{i18n('icu:Donations__badge-modal--display-on-profile')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="DonationThanksModal__help-text">
|
||||
{i18n('icu:Donations__badge-modal--help-text')}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -217,6 +217,7 @@ function renderDonationsPane(props: {
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<PreferencesDonations
|
||||
applyDonationBadge={action('applyDonationBadge')}
|
||||
i18n={i18n}
|
||||
contentsRef={props.contentsRef}
|
||||
clearWorkflow={action('clearWorkflow')}
|
||||
@@ -242,6 +243,10 @@ function renderDonationsPane(props: {
|
||||
showToast={props.showToast}
|
||||
theme={ThemeType.light}
|
||||
updateLastError={action('updateLastError')}
|
||||
donationBadge={undefined}
|
||||
fetchBadgeData={async () => undefined}
|
||||
me={props.me}
|
||||
myProfileChanged={action('myProfileChanged')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ import { Button, ButtonVariant } from './Button';
|
||||
import type {
|
||||
CardDetail,
|
||||
DonationErrorType,
|
||||
DonationStateType,
|
||||
HumanDonationAmount,
|
||||
} from '../types/Donations';
|
||||
import {
|
||||
donationStateSchema,
|
||||
ONE_TIME_DONATION_CONFIG_ID,
|
||||
type DonationWorkflow,
|
||||
type OneTimeDonationHumanAmounts,
|
||||
@@ -90,6 +92,16 @@ type PropsActionType = {
|
||||
|
||||
export type PropsType = PropsDataType & PropsActionType & PropsHousekeepingType;
|
||||
|
||||
const isPaymentDetailFinalizedInWorkflow = (workflow: DonationWorkflow) => {
|
||||
const finalizedStates: Array<DonationStateType> = [
|
||||
donationStateSchema.Enum.INTENT_CONFIRMED,
|
||||
donationStateSchema.Enum.INTENT_REDIRECT,
|
||||
donationStateSchema.Enum.RECEIPT,
|
||||
donationStateSchema.Enum.DONE,
|
||||
];
|
||||
return finalizedStates.includes(workflow.type);
|
||||
};
|
||||
|
||||
export function PreferencesDonateFlow({
|
||||
contentsRef,
|
||||
i18n,
|
||||
@@ -164,12 +176,16 @@ export function PreferencesDonateFlow({
|
||||
|
||||
const onTryClose = useCallback(() => {
|
||||
const onDiscard = () => {
|
||||
clearWorkflow();
|
||||
// Don't clear the workflow if we're processing the payment and
|
||||
// payment information is finalized.
|
||||
if (!workflow || !isPaymentDetailFinalizedInWorkflow(workflow)) {
|
||||
clearWorkflow();
|
||||
}
|
||||
};
|
||||
const isConfirmationNeeded = Boolean(
|
||||
step === 'paymentDetails' &&
|
||||
!isCardFormDisabled &&
|
||||
workflow?.type !== 'DONE'
|
||||
(!workflow || !isPaymentDetailFinalizedInWorkflow(workflow))
|
||||
);
|
||||
|
||||
confirmDiscardIf(isConfirmationNeeded, onDiscard);
|
||||
|
||||
@@ -45,6 +45,13 @@ import { DonationErrorModal } from './DonationErrorModal';
|
||||
import { DonationVerificationModal } from './DonationVerificationModal';
|
||||
import { DonationProgressModal } from './DonationProgressModal';
|
||||
import { DonationStillProcessingModal } from './DonationStillProcessingModal';
|
||||
import { DonationThanksModal } from './DonationThanksModal';
|
||||
import type {
|
||||
ConversationType,
|
||||
ProfileDataType,
|
||||
} from '../state/ducks/conversations';
|
||||
import type { AvatarUpdateOptionsType } from '../types/Avatar';
|
||||
import { drop } from '../util/drop';
|
||||
import { DonationsOfflineTooltip } from './conversation/DonationsOfflineTooltip';
|
||||
import { getInProgressDonation } from '../util/donations';
|
||||
|
||||
@@ -80,9 +87,22 @@ export type PropsDataType = {
|
||||
receipt: DonationReceipt,
|
||||
i18n: LocalizerType
|
||||
) => Promise<Blob>;
|
||||
showToast: (toast: AnyToast) => void;
|
||||
donationBadge: BadgeType | undefined;
|
||||
fetchBadgeData: () => Promise<BadgeType | undefined>;
|
||||
me: ConversationType;
|
||||
myProfileChanged: (
|
||||
profileData: ProfileDataType,
|
||||
avatarUpdateOptions: AvatarUpdateOptionsType
|
||||
) => void;
|
||||
};
|
||||
|
||||
type PropsActionType = {
|
||||
applyDonationBadge: (args: {
|
||||
badge: BadgeType | undefined;
|
||||
applyBadge: boolean;
|
||||
onComplete: (error?: Error) => void;
|
||||
}) => void;
|
||||
clearWorkflow: () => void;
|
||||
resumeWorkflow: () => void;
|
||||
setPage: (page: SettingsPage) => void;
|
||||
@@ -532,6 +552,7 @@ export function PreferencesDonations({
|
||||
workflow,
|
||||
didResumeWorkflowAtStartup,
|
||||
lastError,
|
||||
applyDonationBadge,
|
||||
clearWorkflow,
|
||||
resumeWorkflow,
|
||||
setPage,
|
||||
@@ -548,11 +569,25 @@ export function PreferencesDonations({
|
||||
generateDonationReceiptBlob,
|
||||
showToast,
|
||||
updateLastError,
|
||||
donationBadge,
|
||||
fetchBadgeData,
|
||||
}: PropsType): JSX.Element | null {
|
||||
const [hasProcessingExpired, setHasProcessingExpired] = useState(false);
|
||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||
|
||||
const [isPrivacyModalVisible, setIsPrivacyModalVisible] = useState(false);
|
||||
|
||||
// Fetch badge data when we're about to show the badge modal
|
||||
useEffect(() => {
|
||||
if (
|
||||
workflow?.type === donationStateSchema.Enum.DONE &&
|
||||
page === SettingsPage.Donations &&
|
||||
!donationBadge
|
||||
) {
|
||||
drop(fetchBadgeData());
|
||||
}
|
||||
}, [workflow, page, donationBadge, fetchBadgeData]);
|
||||
|
||||
const navigateToPage = useCallback(
|
||||
(newPage: SettingsPage) => {
|
||||
setPage(newPage);
|
||||
@@ -640,6 +675,27 @@ export function PreferencesDonations({
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (workflow?.type === donationStateSchema.Enum.DONE) {
|
||||
dialog = (
|
||||
<DonationThanksModal
|
||||
i18n={i18n}
|
||||
badge={donationBadge}
|
||||
applyDonationBadge={applyDonationBadge}
|
||||
onClose={(error?: Error) => {
|
||||
clearWorkflow();
|
||||
if (error) {
|
||||
log.error('Badge application failed:', error.message);
|
||||
showToast({
|
||||
toastType: ToastType.DonationCompletedAndBadgeApplicationFailed,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
toastType: ToastType.DonationCompleted,
|
||||
});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (
|
||||
page === SettingsPage.DonationsDonateFlow &&
|
||||
(isSubmitted ||
|
||||
|
||||
@@ -65,6 +65,7 @@ import type { Props as EmojiButtonProps } from './emoji/EmojiButton';
|
||||
import type { EmojiPickDataType } from './emoji/EmojiPicker';
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import type {
|
||||
ConversationType,
|
||||
ProfileDataType,
|
||||
SaveAttachmentActionCreatorType,
|
||||
} from '../state/ducks/conversations';
|
||||
@@ -74,6 +75,10 @@ import type { EmojiVariantKey } from './fun/data/emojis';
|
||||
import type { FunEmojiSelection } from './fun/panels/FunPanelEmojis';
|
||||
import { useConfirmDiscard } from '../hooks/useConfirmDiscard';
|
||||
|
||||
type ProfileEditorData = {
|
||||
firstName: string;
|
||||
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>;
|
||||
|
||||
type PropsExternalType = {
|
||||
onProfileChanged: (
|
||||
profileData: ProfileDataType,
|
||||
@@ -238,7 +243,7 @@ export function ProfileEditor({
|
||||
const [avatarBuffer, setAvatarBuffer] = useState<Uint8Array | undefined>(
|
||||
undefined
|
||||
);
|
||||
const [stagedProfile, setStagedProfile] = useState<ProfileDataType>({
|
||||
const [stagedProfile, setStagedProfile] = useState<ProfileEditorData>({
|
||||
aboutEmoji,
|
||||
aboutText,
|
||||
familyName,
|
||||
|
||||
@@ -41,6 +41,10 @@ function getToast(toastType: ToastType): AnyToast {
|
||||
};
|
||||
case ToastType.Blocked:
|
||||
return { toastType: ToastType.Blocked };
|
||||
case ToastType.DonationCompletedAndBadgeApplicationFailed:
|
||||
return {
|
||||
toastType: ToastType.DonationCompletedAndBadgeApplicationFailed,
|
||||
};
|
||||
case ToastType.BlockedGroup:
|
||||
return { toastType: ToastType.BlockedGroup };
|
||||
case ToastType.CallHistoryCleared:
|
||||
|
||||
@@ -118,6 +118,16 @@ export function renderToast({
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.DonationCompletedAndBadgeApplicationFailed) {
|
||||
return (
|
||||
<Toast onClose={hideToast}>
|
||||
{i18n(
|
||||
'icu:Donations__Toast__DonationCompletedAndBadgeApplicationFailed'
|
||||
)}
|
||||
</Toast>
|
||||
);
|
||||
}
|
||||
|
||||
if (toastType === ToastType.Blocked) {
|
||||
return <Toast onClose={hideToast}>{i18n('icu:unblockToSend')}</Toast>;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import * as Errors from '../types/errors';
|
||||
import { getRandomBytes, sha256 } from '../Crypto';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
import { createLogger } from '../logging/log';
|
||||
import { getProfile } from '../util/getProfile';
|
||||
import { donationValidationCompleteRoute } from '../util/signalRoutes';
|
||||
import { safeParseStrict, safeParseUnknown } from '../util/schemas';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
@@ -376,11 +377,6 @@ export async function _runDonationWorkflow(): Promise<void> {
|
||||
page: SettingsPage.Donations,
|
||||
},
|
||||
});
|
||||
|
||||
// TODO: Replace with DESKTOP-8959
|
||||
window.reduxActions.toast.showToast({
|
||||
toastType: ToastType.DonationCompleted,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
log.info(
|
||||
@@ -735,8 +731,8 @@ export async function _getReceipt(
|
||||
|
||||
// At this point we know that the payment went through, so we save the receipt now.
|
||||
// If the redemption never happens, or fails, the user has it for their tax records.
|
||||
|
||||
await saveReceipt(workflow, logId);
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
type: donationStateSchema.Enum.RECEIPT,
|
||||
@@ -769,14 +765,29 @@ export async function _redeemReceipt(
|
||||
const receiptCredentialPresentationBase64 = Bytes.toBase64(
|
||||
receiptCredentialPresentation.serialize()
|
||||
);
|
||||
|
||||
const me = window.ConversationController.getOurConversationOrThrow();
|
||||
const myBadges = me.attributes.badges;
|
||||
|
||||
const jsonPayload = {
|
||||
receiptCredentialPresentation: receiptCredentialPresentationBase64,
|
||||
visible: false,
|
||||
visible:
|
||||
!!myBadges &&
|
||||
myBadges.length > 0 &&
|
||||
myBadges.every(myBadge => 'isVisible' in myBadge && myBadge.isVisible),
|
||||
primary: false,
|
||||
};
|
||||
|
||||
await window.textsecure.server.redeemReceipt(jsonPayload);
|
||||
|
||||
// After the receipt credential, our profile will change to add new badges.
|
||||
// Refresh our profile to get new badges.
|
||||
await getProfile({
|
||||
serviceId: me.getServiceId() ?? null,
|
||||
e164: me.get('e164') ?? null,
|
||||
groupId: null,
|
||||
});
|
||||
|
||||
log.info(`${logId}: Successfully transitioned to DONE`);
|
||||
|
||||
return {
|
||||
|
||||
@@ -50,6 +50,7 @@ export async function writeProfile(
|
||||
rawAvatarPath,
|
||||
familyName,
|
||||
firstName,
|
||||
badges,
|
||||
} = conversation;
|
||||
|
||||
strictAssert(
|
||||
@@ -141,6 +142,7 @@ export async function writeProfile(
|
||||
aboutEmoji,
|
||||
profileName: firstName,
|
||||
profileFamilyName: familyName,
|
||||
badges: badges ? [...badges] : undefined,
|
||||
...maybeProfileAvatarUpdate,
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import type { StateType as RootStateType } from '../reducer';
|
||||
import type { BadgeType, BadgeImageType } from '../../badges/types';
|
||||
import { getOwn } from '../../util/getOwn';
|
||||
import { badgeImageFileDownloader } from '../../badges/badgeImageFileDownloader';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
|
||||
/**
|
||||
* This duck deals with badge data. Some assumptions it makes:
|
||||
@@ -53,6 +55,10 @@ export const actions = {
|
||||
updateOrCreate,
|
||||
};
|
||||
|
||||
export const useBadgesActions = (): BoundActionCreatorsMapObject<
|
||||
typeof actions
|
||||
> => useBoundActions(actions);
|
||||
|
||||
function badgeImageFileDownloaded(
|
||||
url: string,
|
||||
localPath: string
|
||||
|
||||
@@ -420,9 +420,12 @@ export type ConversationType = ReadonlyDeep<
|
||||
)
|
||||
>;
|
||||
export type ProfileDataType = ReadonlyDeep<
|
||||
{
|
||||
firstName: string;
|
||||
} & Pick<ConversationType, 'aboutEmoji' | 'aboutText' | 'familyName'>
|
||||
Partial<
|
||||
Pick<
|
||||
ConversationType,
|
||||
'firstName' | 'badges' | 'aboutEmoji' | 'aboutText' | 'familyName'
|
||||
>
|
||||
>
|
||||
>;
|
||||
|
||||
export type ConversationLookupType = ReadonlyDeep<{
|
||||
|
||||
@@ -12,6 +12,12 @@ import { DataWriter } from '../../sql/Client';
|
||||
import * as donations from '../../services/donations';
|
||||
import { donationStateSchema } from '../../types/Donations';
|
||||
import { drop } from '../../util/drop';
|
||||
import { storageServiceUploadJob } from '../../services/storage';
|
||||
import { getMe } from '../selectors/conversations';
|
||||
import {
|
||||
type SetProfileUpdateErrorActionType,
|
||||
actions as conversationActions,
|
||||
} from './conversations';
|
||||
|
||||
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
|
||||
import type {
|
||||
@@ -21,6 +27,8 @@ import type {
|
||||
DonationWorkflow,
|
||||
StripeDonationAmount,
|
||||
} from '../../types/Donations';
|
||||
import type { BadgeType } from '../../badges/types';
|
||||
import type { ProfileDataType } from './conversations';
|
||||
import type { StateType as RootStateType } from '../reducer';
|
||||
|
||||
const log = createLogger('donations');
|
||||
@@ -207,8 +215,115 @@ function updateWorkflow(
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDonationBadge({
|
||||
badge,
|
||||
applyBadge,
|
||||
onComplete,
|
||||
}: {
|
||||
badge: BadgeType | undefined;
|
||||
applyBadge: boolean;
|
||||
onComplete: (error?: Error) => void;
|
||||
}): ThunkAction<void, RootStateType, unknown, SetProfileUpdateErrorActionType> {
|
||||
return async (dispatch, getState) => {
|
||||
const me = getMe(getState());
|
||||
|
||||
if (!badge) {
|
||||
onComplete(new Error('No badge was given to redeem'));
|
||||
return;
|
||||
}
|
||||
|
||||
const allBadgesHaveVisibilityData = me.badges.every(
|
||||
myBadge => 'isVisible' in myBadge
|
||||
);
|
||||
|
||||
const desiredBadgeIndexInUserBadges = me.badges.findIndex(
|
||||
myBadge => myBadge.id === badge.id
|
||||
);
|
||||
|
||||
const userHasDesiredBadgeToApply = desiredBadgeIndexInUserBadges !== -1;
|
||||
const desiredBadgeInUserProfile =
|
||||
me.badges?.[desiredBadgeIndexInUserBadges];
|
||||
|
||||
if (!userHasDesiredBadgeToApply || !desiredBadgeInUserProfile) {
|
||||
onComplete(new Error('User does not have the desired badge to apply'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!allBadgesHaveVisibilityData ||
|
||||
!('isVisible' in desiredBadgeInUserProfile)
|
||||
) {
|
||||
onComplete(
|
||||
new Error("Unable to determine user's existing visible badges")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const previousDisplayBadgesOnProfile =
|
||||
me.badges.length > 0 &&
|
||||
me.badges.every(myBadge => 'isVisible' in myBadge && myBadge.isVisible);
|
||||
|
||||
const otherBadges = me.badges?.filter(b => b.id !== badge.id) ?? [];
|
||||
|
||||
let newDisplayBadgesOnProfile = previousDisplayBadgesOnProfile;
|
||||
|
||||
if (applyBadge) {
|
||||
// Add the badge to the front and make ALL badges visible
|
||||
const updatedBadges = [
|
||||
{ id: badge.id, isVisible: true },
|
||||
...otherBadges.map(b => ({ ...b, isVisible: true })),
|
||||
];
|
||||
|
||||
// Note: We pass only the badges we want visible to myProfileChanged.
|
||||
// This is how the API works - we're not "deleting" invisible badges,
|
||||
// we're setting the complete list of visible badges.
|
||||
const profileData: ProfileDataType = {
|
||||
badges: updatedBadges,
|
||||
};
|
||||
|
||||
await dispatch(
|
||||
conversationActions.myProfileChanged(profileData, { keepAvatar: true })
|
||||
);
|
||||
newDisplayBadgesOnProfile = true;
|
||||
} else if (
|
||||
// If we're here, the user has unchecked the setting to apply the badge.
|
||||
// If the badge we want to apply is already the primary visible badge, we
|
||||
// disable showing badges.
|
||||
// If the user has another badge as primary, we do nothing and keep it.
|
||||
desiredBadgeIndexInUserBadges === 0 &&
|
||||
desiredBadgeInUserProfile.isVisible
|
||||
) {
|
||||
const profileData: ProfileDataType = {
|
||||
badges: [],
|
||||
};
|
||||
|
||||
await dispatch(
|
||||
conversationActions.myProfileChanged(profileData, { keepAvatar: true })
|
||||
);
|
||||
newDisplayBadgesOnProfile = false;
|
||||
}
|
||||
|
||||
const storageValue = window.storage.get('displayBadgesOnProfile');
|
||||
if (
|
||||
storageValue == null ||
|
||||
previousDisplayBadgesOnProfile !== newDisplayBadgesOnProfile
|
||||
) {
|
||||
await window.storage.put(
|
||||
'displayBadgesOnProfile',
|
||||
newDisplayBadgesOnProfile
|
||||
);
|
||||
if (previousDisplayBadgesOnProfile !== newDisplayBadgesOnProfile) {
|
||||
storageServiceUploadJob({ reason: 'donation-badge-toggle' });
|
||||
}
|
||||
}
|
||||
|
||||
onComplete();
|
||||
};
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
addReceipt,
|
||||
applyDonationBadge,
|
||||
clearWorkflow,
|
||||
internalAddDonationReceipt,
|
||||
setDidResume,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React, { memo, useEffect, useState } from 'react';
|
||||
import React, { memo, useEffect, useState, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import type { MutableRefObject } from 'react';
|
||||
@@ -12,16 +12,26 @@ import { PreferencesDonations } from '../../components/PreferencesDonations';
|
||||
import type { SettingsPage } from '../../types/Nav';
|
||||
import { useDonationsActions } from '../ducks/donations';
|
||||
import type { StateType } from '../reducer';
|
||||
import { useConversationsActions } from '../ducks/conversations';
|
||||
import { isStagingServer } from '../../util/isStagingServer';
|
||||
import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt';
|
||||
import { useToastActions } from '../ducks/toast';
|
||||
import { getDonationHumanAmounts } from '../../util/subscriptionConfiguration';
|
||||
import {
|
||||
getDonationHumanAmounts,
|
||||
getCachedSubscriptionConfiguration,
|
||||
} from '../../util/subscriptionConfiguration';
|
||||
import { drop } from '../../util/drop';
|
||||
import type { OneTimeDonationHumanAmounts } from '../../types/Donations';
|
||||
import { getPreferredBadgeSelector } from '../selectors/badges';
|
||||
import { ONE_TIME_DONATION_CONFIG_ID, BOOST_ID } from '../../types/Donations';
|
||||
import { phoneNumberToCurrencyCode } from '../../services/donations';
|
||||
import { getPreferredBadgeSelector, getBadgesById } from '../selectors/badges';
|
||||
import { parseBoostBadgeListFromServer } from '../../badges/parseBadgesFromServer';
|
||||
import { createLogger } from '../../logging/log';
|
||||
import { useBadgesActions } from '../ducks/badges';
|
||||
import { getNetworkIsOnline } from '../selectors/network';
|
||||
|
||||
const log = createLogger('SmartPreferencesDonations');
|
||||
|
||||
export const SmartPreferencesDonations = memo(
|
||||
function SmartPreferencesDonations({
|
||||
contentsRef,
|
||||
@@ -46,11 +56,19 @@ export const SmartPreferencesDonations = memo(
|
||||
const theme = useSelector(getTheme);
|
||||
|
||||
const donationsState = useSelector((state: StateType) => state.donations);
|
||||
const { clearWorkflow, resumeWorkflow, submitDonation, updateLastError } =
|
||||
useDonationsActions();
|
||||
const {
|
||||
applyDonationBadge,
|
||||
clearWorkflow,
|
||||
resumeWorkflow,
|
||||
submitDonation,
|
||||
updateLastError,
|
||||
} = useDonationsActions();
|
||||
const { myProfileChanged } = useConversationsActions();
|
||||
|
||||
const badgesById = useSelector(getBadgesById);
|
||||
const ourNumber = useSelector(getUserNumber);
|
||||
const { badges, color, firstName, profileAvatarUrl } = useSelector(getMe);
|
||||
const me = useSelector(getMe);
|
||||
const { badges, color, firstName, profileAvatarUrl } = me;
|
||||
const badge = getPreferredBadge(badges);
|
||||
|
||||
const { showToast } = useToastActions();
|
||||
@@ -59,6 +77,27 @@ export const SmartPreferencesDonations = memo(
|
||||
);
|
||||
|
||||
const { saveAttachmentToDisk } = window.Signal.Migrations;
|
||||
const { updateOrCreate } = useBadgesActions();
|
||||
|
||||
// Function to fetch donation badge data
|
||||
const fetchBadgeData = useCallback(async () => {
|
||||
try {
|
||||
const subscriptionConfig = await getCachedSubscriptionConfiguration();
|
||||
const badgeData = parseBoostBadgeListFromServer(
|
||||
subscriptionConfig,
|
||||
window.SignalContext.config.updatesUrl
|
||||
);
|
||||
|
||||
const boostBadge = badgeData[ONE_TIME_DONATION_CONFIG_ID];
|
||||
if (boostBadge) {
|
||||
updateOrCreate([boostBadge]);
|
||||
return boostBadge;
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('Failed to load donation badge:', error);
|
||||
}
|
||||
return undefined;
|
||||
}, [updateOrCreate]);
|
||||
|
||||
// Eagerly load donation config from API when entering Donations Home so the
|
||||
// Amount picker loads instantly
|
||||
@@ -78,6 +117,10 @@ export const SmartPreferencesDonations = memo(
|
||||
const initialCurrency = validCurrencies.includes(currencyFromPhone)
|
||||
? currencyFromPhone
|
||||
: 'usd';
|
||||
// Load badge data on mount
|
||||
useEffect(() => {
|
||||
drop(fetchBadgeData());
|
||||
}, [fetchBadgeData]);
|
||||
|
||||
return (
|
||||
<PreferencesDonations
|
||||
@@ -100,12 +143,17 @@ export const SmartPreferencesDonations = memo(
|
||||
didResumeWorkflowAtStartup={donationsState.didResumeWorkflowAtStartup}
|
||||
lastError={donationsState.lastError}
|
||||
workflow={donationsState.currentWorkflow}
|
||||
applyDonationBadge={applyDonationBadge}
|
||||
clearWorkflow={clearWorkflow}
|
||||
resumeWorkflow={resumeWorkflow}
|
||||
updateLastError={updateLastError}
|
||||
submitDonation={submitDonation}
|
||||
setPage={setPage}
|
||||
theme={theme}
|
||||
donationBadge={badgesById[BOOST_ID] ?? undefined}
|
||||
fetchBadgeData={fetchBadgeData}
|
||||
me={me}
|
||||
myProfileChanged={myProfileChanged}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
322
ts/test-node/state/ducks/donations_test.ts
Normal file
322
ts/test-node/state/ducks/donations_test.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { assert } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
|
||||
import type { StateType } from '../../../state/reducer';
|
||||
import { reducer as rootReducer } from '../../../state/reducer';
|
||||
import { noopAction } from '../../../state/ducks/noop';
|
||||
import { applyDonationBadge } from '../../../state/ducks/donations';
|
||||
import * as conversations from '../../../state/ducks/conversations';
|
||||
import type { BadgeType } from '../../../badges/types';
|
||||
import { BadgeCategory } from '../../../badges/BadgeCategory';
|
||||
import type { ConversationType } from '../../../state/ducks/conversations';
|
||||
import { generateAci } from '../../../types/ServiceId';
|
||||
|
||||
describe('donations duck', () => {
|
||||
const getEmptyRootState = (): StateType =>
|
||||
rootReducer(undefined, noopAction());
|
||||
|
||||
describe('applyDonationBadge thunk', () => {
|
||||
let sandbox: sinon.SinonSandbox;
|
||||
let myProfileChangedStub: sinon.SinonStub;
|
||||
let originalMyProfileChanged: typeof conversations.actions.myProfileChanged;
|
||||
|
||||
const TEST_BADGE: BadgeType = {
|
||||
id: 'boost-badge',
|
||||
category: BadgeCategory.Donor,
|
||||
name: 'Boost',
|
||||
descriptionTemplate: 'Boost badge',
|
||||
images: [],
|
||||
};
|
||||
|
||||
const ourConversationId = 'our-conversation-id';
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = sinon.createSandbox();
|
||||
|
||||
// Clear storage for each test
|
||||
await window.storage.remove('displayBadgesOnProfile');
|
||||
|
||||
// Mock myProfileChanged by replacing the function directly
|
||||
myProfileChangedStub = sandbox.stub().returns(() => Promise.resolve());
|
||||
originalMyProfileChanged = conversations.actions.myProfileChanged;
|
||||
conversations.actions.myProfileChanged = myProfileChangedStub;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Restore original myProfileChanged
|
||||
conversations.actions.myProfileChanged = originalMyProfileChanged;
|
||||
sandbox.restore();
|
||||
// Clean up storage
|
||||
await window.storage.remove('displayBadgesOnProfile');
|
||||
});
|
||||
|
||||
const createMeWithBadges = (
|
||||
badges: Array<{
|
||||
id: string;
|
||||
isVisible?: boolean;
|
||||
}>
|
||||
): ConversationType => ({
|
||||
id: ourConversationId,
|
||||
serviceId: generateAci(),
|
||||
badges,
|
||||
type: 'direct',
|
||||
title: 'Me',
|
||||
acceptedMessageRequest: true,
|
||||
isMe: true,
|
||||
sharedGroupNames: [],
|
||||
});
|
||||
|
||||
const createRootState = (me: ConversationType): StateType => {
|
||||
const state = getEmptyRootState();
|
||||
|
||||
return {
|
||||
...state,
|
||||
user: {
|
||||
...state.user,
|
||||
ourConversationId,
|
||||
},
|
||||
conversations: {
|
||||
...state.conversations,
|
||||
conversationLookup: {
|
||||
[ourConversationId]: me,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// Helper to create test setup for a scenario
|
||||
const setupTest = async (
|
||||
badges: Array<{ id: string; isVisible?: boolean }>
|
||||
) => {
|
||||
const me = createMeWithBadges(badges);
|
||||
const rootState = createRootState(me);
|
||||
const getState = () => rootState;
|
||||
const dispatch = sandbox.stub().callsFake(async (action: unknown) => {
|
||||
if (typeof action === 'function') {
|
||||
return action(dispatch, getState);
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
const onComplete = sandbox.stub();
|
||||
|
||||
// Helper to execute applyDonationBadge with common params
|
||||
const executeApplyDonationBadge = async (
|
||||
badge: BadgeType | undefined,
|
||||
applyBadge: boolean
|
||||
) => {
|
||||
await applyDonationBadge({
|
||||
badge,
|
||||
applyBadge,
|
||||
onComplete,
|
||||
})(dispatch, getState, null);
|
||||
};
|
||||
|
||||
return { me, onComplete, executeApplyDonationBadge };
|
||||
};
|
||||
|
||||
describe('Modal States', () => {
|
||||
describe('All badges invisible (previousDisplayBadgesOnProfile = false)', () => {
|
||||
it('Submit ON: Makes ALL badges visible, boost primary', async () => {
|
||||
// Setup: Boost invisible (along with other badges)
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'other-badge', isVisible: false },
|
||||
{ id: 'boost-badge', isVisible: false },
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle ON (checkbox checked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, true);
|
||||
|
||||
// Result: Boost becomes visible and primary,
|
||||
// ALL other badges become visible
|
||||
sinon.assert.calledOnce(myProfileChangedStub);
|
||||
const profileData = myProfileChangedStub.getCall(0).args[0];
|
||||
assert.deepEqual(profileData.badges, [
|
||||
{ id: 'boost-badge', isVisible: true }, // Primary
|
||||
{ id: 'other-badge', isVisible: true }, // Now visible too
|
||||
]);
|
||||
|
||||
// Verify storage was updated from false to true
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), true);
|
||||
|
||||
// Note: storageServiceUploadJob would be called here with
|
||||
// { reason: 'donation-badge-toggle' } but we can't spy on const exports
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
|
||||
it('Submit OFF: No change', async () => {
|
||||
// Setup: Boost invisible
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'boost-badge', isVisible: false },
|
||||
{ id: 'other-badge', isVisible: false },
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle OFF (checkbox unchecked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, false);
|
||||
|
||||
// Result: No change (badges remain invisible)
|
||||
// Since boost is not primary, nothing happens
|
||||
sinon.assert.notCalled(myProfileChangedStub);
|
||||
|
||||
// Verify storage was written with false (even though unchanged)
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), false);
|
||||
// Note: storageServiceUploadJob would not be called here
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All badges visible, boost primary (previousDisplayBadgesOnProfile = true)', () => {
|
||||
it('Submit ON: No change', async () => {
|
||||
// Setup: Boost primary
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'boost-badge', isVisible: true }, // Primary (index 0)
|
||||
{ id: 'other-badge', isVisible: true },
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle ON (checkbox checked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, true);
|
||||
|
||||
// Result: No change (boost remains primary)
|
||||
// myProfileChanged still called but with same order
|
||||
sinon.assert.calledOnce(myProfileChangedStub);
|
||||
const profileData = myProfileChangedStub.getCall(0).args[0];
|
||||
assert.deepEqual(profileData.badges, [
|
||||
{ id: 'boost-badge', isVisible: true }, // Still primary
|
||||
{ id: 'other-badge', isVisible: true },
|
||||
]);
|
||||
|
||||
// Verify storage remains at true (no update needed)
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), true);
|
||||
// Note: storageServiceUploadJob would not be called here (no change)
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
|
||||
it('Submit OFF: Hides all badges', async () => {
|
||||
// Setup: Boost primary
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'boost-badge', isVisible: true }, // Primary (index 0)
|
||||
{ id: 'other-badge', isVisible: true },
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle OFF (checkbox unchecked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, false);
|
||||
|
||||
// Result: All badges become invisible
|
||||
sinon.assert.calledOnce(myProfileChangedStub);
|
||||
const profileData = myProfileChangedStub.getCall(0).args[0];
|
||||
assert.deepEqual(profileData.badges, []);
|
||||
|
||||
// Verify storage was updated from true to false
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), false);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
});
|
||||
|
||||
describe('All badges visible, boost not primary (previousDisplayBadgesOnProfile = true)', () => {
|
||||
it('Submit ON: Makes boost primary', async () => {
|
||||
// Setup: Boost not primary (other badge is index 0)
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'other-badge', isVisible: true }, // Primary (index 0)
|
||||
{ id: 'boost-badge', isVisible: true }, // Not primary
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle ON (checkbox checked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, true);
|
||||
|
||||
// Result: Boost moves to primary position
|
||||
sinon.assert.calledOnce(myProfileChangedStub);
|
||||
const profileData = myProfileChangedStub.getCall(0).args[0];
|
||||
assert.deepEqual(profileData.badges, [
|
||||
{ id: 'boost-badge', isVisible: true }, // Moved to primary
|
||||
{ id: 'other-badge', isVisible: true }, // Previous primary shifts down
|
||||
]);
|
||||
|
||||
// Verify storage remains at true (no update needed)
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), true);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
|
||||
it('Submit OFF: No change', async () => {
|
||||
// Setup: Boost not primary (other badge is index 0)
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'other-badge', isVisible: true }, // Primary (index 0)
|
||||
{ id: 'boost-badge', isVisible: true }, // Not primary
|
||||
]);
|
||||
|
||||
// Action: Submit with toggle OFF (checkbox unchecked)
|
||||
await executeApplyDonationBadge(TEST_BADGE, false);
|
||||
|
||||
// Result: No change (other badge remains primary)
|
||||
sinon.assert.notCalled(myProfileChangedStub);
|
||||
|
||||
// Verify storage remains at true (no update needed)
|
||||
assert.equal(window.storage.get('displayBadgesOnProfile'), true);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(onComplete);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Scenarios', () => {
|
||||
it('No Badge Data: should show error', async () => {
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([]);
|
||||
|
||||
// Modal receives undefined badge
|
||||
await executeApplyDonationBadge(undefined, true);
|
||||
|
||||
sinon.assert.calledOnce(onComplete);
|
||||
const error = onComplete.getCall(0).args[0];
|
||||
assert.instanceOf(error, Error);
|
||||
assert.equal(error.message, 'No badge was given to redeem');
|
||||
|
||||
sinon.assert.notCalled(myProfileChangedStub);
|
||||
});
|
||||
|
||||
it('Badge Visibility Data Corrupted: should show error', async () => {
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'boost-badge' }, // Missing isVisible property
|
||||
]);
|
||||
|
||||
await executeApplyDonationBadge(TEST_BADGE, true);
|
||||
|
||||
// Should show error toast
|
||||
sinon.assert.calledOnce(onComplete);
|
||||
const error = onComplete.getCall(0).args[0];
|
||||
assert.instanceOf(error, Error);
|
||||
assert.equal(
|
||||
error.message,
|
||||
"Unable to determine user's existing visible badges"
|
||||
);
|
||||
|
||||
sinon.assert.notCalled(myProfileChangedStub);
|
||||
});
|
||||
|
||||
it("User Doesn't Have Badge: should show error", async () => {
|
||||
const { onComplete, executeApplyDonationBadge } = await setupTest([
|
||||
{ id: 'other-badge', isVisible: true },
|
||||
// boost-badge not in list
|
||||
]);
|
||||
|
||||
await executeApplyDonationBadge(TEST_BADGE, true);
|
||||
|
||||
// Should show error toast
|
||||
sinon.assert.calledOnce(onComplete);
|
||||
const error = onComplete.getCall(0).args[0];
|
||||
assert.instanceOf(error, Error);
|
||||
assert.equal(
|
||||
error.message,
|
||||
'User does not have the desired badge to apply'
|
||||
);
|
||||
|
||||
sinon.assert.notCalled(myProfileChangedStub);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ONE_TIME_DONATION_CONFIG_ID = '1';
|
||||
export const BOOST_ID = 'BOOST';
|
||||
|
||||
export const donationStateSchema = z.enum([
|
||||
'INTENT',
|
||||
@@ -14,6 +15,8 @@ export const donationStateSchema = z.enum([
|
||||
'DONE',
|
||||
]);
|
||||
|
||||
export type DonationStateType = z.infer<typeof donationStateSchema>;
|
||||
|
||||
export const donationErrorTypeSchema = z.enum([
|
||||
// Used if the user is redirected back from validation, but continuing forward fails
|
||||
'Failed3dsValidation',
|
||||
|
||||
@@ -8,6 +8,7 @@ export enum ToastType {
|
||||
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
|
||||
AttachmentDownloadFailed = 'AttachmentDownloadFailed',
|
||||
AttachmentDownloadStillInProgress = 'AttachmentDownloadStillInProgress',
|
||||
DonationCompletedAndBadgeApplicationFailed = 'DonationCompletedAndBadgeApplicationFailed',
|
||||
Blocked = 'Blocked',
|
||||
BlockedGroup = 'BlockedGroup',
|
||||
CallHistoryCleared = 'CallHistoryCleared',
|
||||
@@ -103,6 +104,7 @@ export type AnyToast =
|
||||
toastType: ToastType.AttachmentDownloadStillInProgress;
|
||||
parameters: { count: number };
|
||||
}
|
||||
| { toastType: ToastType.DonationCompletedAndBadgeApplicationFailed }
|
||||
| { toastType: ToastType.Blocked }
|
||||
| { toastType: ToastType.BlockedGroup }
|
||||
| { toastType: ToastType.CallHistoryCleared }
|
||||
|
||||
Reference in New Issue
Block a user