Post Donate Badge Toggle Modal

This commit is contained in:
yash-signal
2025-08-12 19:35:52 -05:00
committed by GitHub
parent ae3e7cfc41
commit 78c1559f76
20 changed files with 892 additions and 19 deletions

View File

@@ -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",

View 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
);
}
}

View File

@@ -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';

View 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,
};

View 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>
);
}

View File

@@ -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')}
/>
);
}

View File

@@ -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);

View File

@@ -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 ||

View File

@@ -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,

View File

@@ -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:

View File

@@ -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>;
}

View File

@@ -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 {

View File

@@ -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,
});

View File

@@ -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

View File

@@ -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<{

View File

@@ -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,

View File

@@ -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}
/>
);
}

View 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);
});
});
});
});

View File

@@ -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',

View File

@@ -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 }