diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 25e3be5d85..384abf9f95 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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", diff --git a/stylesheets/components/DonationThanksModal.scss b/stylesheets/components/DonationThanksModal.scss new file mode 100644 index 0000000000..699afeacbe --- /dev/null +++ b/stylesheets/components/DonationThanksModal.scss @@ -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 + ); + } +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index f2e41e88e9..450776647c 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/DonationThanksModal.stories.tsx b/ts/components/DonationThanksModal.stories.tsx new file mode 100644 index 0000000000..4054f71aac --- /dev/null +++ b/ts/components/DonationThanksModal.stories.tsx @@ -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; + +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 = args => ; + +export const Default = Template.bind({}); +Default.args = defaultProps; + +export const LoadingBadge = Template.bind({}); +LoadingBadge.args = { + ...defaultProps, + badge: undefined, +}; diff --git a/ts/components/DonationThanksModal.tsx b/ts/components/DonationThanksModal.tsx new file mode 100644 index 0000000000..4452ac2f70 --- /dev/null +++ b/ts/components/DonationThanksModal.tsx @@ -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 ( + + {i18n('icu:done')} + + } + > +
+
+ {badge ? ( + + ) : ( + + )} +
+ +
+

+ {i18n('icu:Donations__badge-modal--title')} +

+

+ {i18n('icu:Donations__badge-modal--description')} +

+
+ +
+ +
+ + + {i18n('icu:Donations__badge-modal--display-on-profile')} + +
+ +
+ {i18n('icu:Donations__badge-modal--help-text')} +
+
+ + ); +} diff --git a/ts/components/Preferences.stories.tsx b/ts/components/Preferences.stories.tsx index 3551219bc7..26d69579de 100644 --- a/ts/components/Preferences.stories.tsx +++ b/ts/components/Preferences.stories.tsx @@ -217,6 +217,7 @@ function renderDonationsPane(props: { }): JSX.Element { return ( undefined} + me={props.me} + myProfileChanged={action('myProfileChanged')} /> ); } diff --git a/ts/components/PreferencesDonateFlow.tsx b/ts/components/PreferencesDonateFlow.tsx index d4281d9639..19b6459399 100644 --- a/ts/components/PreferencesDonateFlow.tsx +++ b/ts/components/PreferencesDonateFlow.tsx @@ -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 = [ + 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); diff --git a/ts/components/PreferencesDonations.tsx b/ts/components/PreferencesDonations.tsx index 66ca0d0158..55cb81e8e7 100644 --- a/ts/components/PreferencesDonations.tsx +++ b/ts/components/PreferencesDonations.tsx @@ -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; + showToast: (toast: AnyToast) => void; + donationBadge: BadgeType | undefined; + fetchBadgeData: () => Promise; + 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 = ( + { + 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 || diff --git a/ts/components/ProfileEditor.tsx b/ts/components/ProfileEditor.tsx index e27a4be2ce..bdb4bc9f06 100644 --- a/ts/components/ProfileEditor.tsx +++ b/ts/components/ProfileEditor.tsx @@ -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; + type PropsExternalType = { onProfileChanged: ( profileData: ProfileDataType, @@ -238,7 +243,7 @@ export function ProfileEditor({ const [avatarBuffer, setAvatarBuffer] = useState( undefined ); - const [stagedProfile, setStagedProfile] = useState({ + const [stagedProfile, setStagedProfile] = useState({ aboutEmoji, aboutText, familyName, diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index 814eb18925..fc94bd604e 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -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: diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 62731d1bd6..6f68e4295a 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -118,6 +118,16 @@ export function renderToast({ ); } + if (toastType === ToastType.DonationCompletedAndBadgeApplicationFailed) { + return ( + + {i18n( + 'icu:Donations__Toast__DonationCompletedAndBadgeApplicationFailed' + )} + + ); + } + if (toastType === ToastType.Blocked) { return {i18n('icu:unblockToSend')}; } diff --git a/ts/services/donations.ts b/ts/services/donations.ts index dc4fe8a425..8c0759a953 100644 --- a/ts/services/donations.ts +++ b/ts/services/donations.ts @@ -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 { 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 { diff --git a/ts/services/writeProfile.ts b/ts/services/writeProfile.ts index 4cae466806..eb7fb0d660 100644 --- a/ts/services/writeProfile.ts +++ b/ts/services/writeProfile.ts @@ -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, }); diff --git a/ts/state/ducks/badges.ts b/ts/state/ducks/badges.ts index 8f09ef95e4..800975bb6f 100644 --- a/ts/state/ducks/badges.ts +++ b/ts/state/ducks/badges.ts @@ -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 diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 884029d310..5051d79d15 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -420,9 +420,12 @@ export type ConversationType = ReadonlyDeep< ) >; export type ProfileDataType = ReadonlyDeep< - { - firstName: string; - } & Pick + Partial< + Pick< + ConversationType, + 'firstName' | 'badges' | 'aboutEmoji' | 'aboutText' | 'familyName' + > + > >; export type ConversationLookupType = ReadonlyDeep<{ diff --git a/ts/state/ducks/donations.ts b/ts/state/ducks/donations.ts index 43543dd18d..a52e5ea811 100644 --- a/ts/state/ducks/donations.ts +++ b/ts/state/ducks/donations.ts @@ -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 { + 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, diff --git a/ts/state/smart/PreferencesDonations.tsx b/ts/state/smart/PreferencesDonations.tsx index 7a49a2d85f..e46d2988d7 100644 --- a/ts/state/smart/PreferencesDonations.tsx +++ b/ts/state/smart/PreferencesDonations.tsx @@ -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 ( ); } diff --git a/ts/test-node/state/ducks/donations_test.ts b/ts/test-node/state/ducks/donations_test.ts new file mode 100644 index 0000000000..b5205054fd --- /dev/null +++ b/ts/test-node/state/ducks/donations_test.ts @@ -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); + }); + }); + }); +}); diff --git a/ts/types/Donations.ts b/ts/types/Donations.ts index de0208836f..954bd375a8 100644 --- a/ts/types/Donations.ts +++ b/ts/types/Donations.ts @@ -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; + export const donationErrorTypeSchema = z.enum([ // Used if the user is redirected back from validation, but continuing forward fails 'Failed3dsValidation', diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index 02fe916fd5..6cdd5fca80 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -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 }