From ff9d247cb24e5ecb849ee6555b653b73155900c8 Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Tue, 10 Feb 2026 15:45:25 -0800 Subject: [PATCH] Fix megaphone donate cta to navigate to amount picker and cache config in redux --- ts/components/PreferencesDonateFlow.dom.tsx | 5 ++- ts/components/PreferencesDonations.dom.tsx | 3 +- ts/services/donationsLoader.preload.ts | 1 + ts/services/megaphone.preload.ts | 11 +++++ ts/state/ducks/donations.preload.ts | 27 ++++++++++++ ts/state/ducks/megaphones.preload.ts | 2 +- ts/state/selectors/donations.std.ts | 21 ++++++++++ .../smart/PreferencesDonations.preload.tsx | 31 +++++++------- ts/util/subscriptionConfiguration.preload.ts | 42 ++++++++++++++++--- ts/windows/main/preload_test.preload.ts | 1 + 10 files changed, 118 insertions(+), 26 deletions(-) create mode 100644 ts/state/selectors/donations.std.ts diff --git a/ts/components/PreferencesDonateFlow.dom.tsx b/ts/components/PreferencesDonateFlow.dom.tsx index eab22a1dfc..ec8b54766e 100644 --- a/ts/components/PreferencesDonateFlow.dom.tsx +++ b/ts/components/PreferencesDonateFlow.dom.tsx @@ -11,6 +11,7 @@ import React, { } from 'react'; import classNames from 'classnames'; +import type { ReadonlyDeep } from 'type-fest'; import type { LocalizerType } from '../types/Util.std.js'; import { useConfirmDiscard } from '../hooks/useConfirmDiscard.dom.js'; import { @@ -87,7 +88,7 @@ export type PropsDataType = { initialCurrency: string; isDonationPaypalEnabled: boolean; isOnline: boolean; - donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + donationAmountsConfig: ReadonlyDeep | undefined; lastError: DonationErrorType | undefined; validCurrencies: ReadonlyArray; workflow: DonationWorkflow | undefined; @@ -555,7 +556,7 @@ type AmountPickerProps = { initialAmount: HumanDonationAmount | undefined; initialCurrency: string | undefined; isOnline: boolean; - donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + donationAmountsConfig: ReadonlyDeep | undefined; validCurrencies: ReadonlyArray; onChangeCurrency: (value: string) => void; onSubmit: (result: AmountPickerResult) => void; diff --git a/ts/components/PreferencesDonations.dom.tsx b/ts/components/PreferencesDonations.dom.tsx index fc4a851303..7667317b1c 100644 --- a/ts/components/PreferencesDonations.dom.tsx +++ b/ts/components/PreferencesDonations.dom.tsx @@ -6,6 +6,7 @@ import lodash from 'lodash'; import type { MutableRefObject, ReactNode } from 'react'; import { ListBox, ListBoxItem } from 'react-aria-components'; +import type { ReadonlyDeep } from 'type-fest'; import { getDateTimeFormatter } from '../util/formatTimestamp.dom.js'; import type { LocalizerType, ThemeType } from '../types/Util.std.js'; @@ -79,7 +80,7 @@ export type PropsDataType = { color: AvatarColorType | undefined; firstName: string | undefined; profileAvatarUrl?: string; - donationAmountsConfig: OneTimeDonationHumanAmounts | undefined; + donationAmountsConfig: ReadonlyDeep | undefined; validCurrencies: ReadonlyArray; donationReceipts: ReadonlyArray; theme: ThemeType; diff --git a/ts/services/donationsLoader.preload.ts b/ts/services/donationsLoader.preload.ts index fea043c2ff..69758a3613 100644 --- a/ts/services/donationsLoader.preload.ts +++ b/ts/services/donationsLoader.preload.ts @@ -27,5 +27,6 @@ export function getDonationsForRedux(): DonationsStateType { didResumeWorkflowAtStartup: Boolean(currentWorkflow), lastError: undefined, receipts: donationReceipts, + configCache: undefined, }; } diff --git a/ts/services/megaphone.preload.ts b/ts/services/megaphone.preload.ts index 12d5d8c0aa..23ace4d490 100644 --- a/ts/services/megaphone.preload.ts +++ b/ts/services/megaphone.preload.ts @@ -21,6 +21,7 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js' import { itemStorage } from '../textsecure/Storage.preload.js'; import { isMoreRecentThan } from '../util/timestamp.std.js'; import { isFeaturedEnabledNoRedux } from '../util/isFeatureEnabled.dom.js'; +import { maybeHydrateDonationConfigCache } from '../util/subscriptionConfiguration.preload.js'; const log = createLogger('megaphoneService'); @@ -140,6 +141,16 @@ async function processMegaphone(megaphone: RemoteMegaphoneType): Promise { } if (isMegaphoneShowable(megaphone)) { + if ( + megaphone.primaryCtaId === 'donate' || + megaphone.secondaryCtaId === 'donate' + ) { + log.info( + 'processMegaphone: Megaphone ctaId donate, prefetching donation amount config' + ); + drop(maybeHydrateDonationConfigCache()); + } + log.info(`processMegaphone: Showing ${id}`); window.reduxActions.megaphones.addVisibleMegaphone(megaphone); } diff --git a/ts/state/ducks/donations.preload.ts b/ts/state/ducks/donations.preload.ts index 2f11c34568..d6eab4b610 100644 --- a/ts/state/ducks/donations.preload.ts +++ b/ts/state/ducks/donations.preload.ts @@ -29,6 +29,7 @@ import type { DonationErrorType, DonationReceipt, DonationWorkflow, + OneTimeDonationHumanAmounts, StripeDonationAmount, } from '../../types/Donations.std.js'; import type { BadgeType } from '../../badges/types.std.js'; @@ -45,11 +46,13 @@ export type DonationsStateType = ReadonlyDeep<{ didResumeWorkflowAtStartup: boolean; lastError: DonationErrorType | undefined; receipts: Array; + configCache: OneTimeDonationHumanAmounts | undefined; }>; // Actions export const ADD_RECEIPT = 'donations/ADD_RECEIPT'; +export const HYDRATE_CONFIG_CACHE = 'donations/HYDRATE_CONFIG_CACHE'; export const SUBMIT_DONATION = 'donations/SUBMIT_DONATION'; export const UPDATE_WORKFLOW = 'donations/UPDATE_WORKFLOW'; export const UPDATE_LAST_ERROR = 'donations/UPDATE_LAST_ERROR'; @@ -60,6 +63,11 @@ export type AddReceiptAction = ReadonlyDeep<{ payload: { receipt: DonationReceipt }; }>; +export type HydrateConfigCacheAction = ReadonlyDeep<{ + type: typeof HYDRATE_CONFIG_CACHE; + payload: { configCache: OneTimeDonationHumanAmounts }; +}>; + export type SetDidResumeAction = ReadonlyDeep<{ type: typeof SET_DID_RESUME; payload: boolean; @@ -82,6 +90,7 @@ export type UpdateWorkflowAction = ReadonlyDeep<{ export type DonationsActionType = ReadonlyDeep< | AddReceiptAction + | HydrateConfigCacheAction | SetDidResumeAction | SubmitDonationAction | UpdateLastErrorAction @@ -120,6 +129,15 @@ function internalAddDonationReceipt( }; } +function hydrateConfigCache( + configCache: OneTimeDonationHumanAmounts +): HydrateConfigCacheAction { + return { + type: HYDRATE_CONFIG_CACHE, + payload: { configCache }, + }; +} + function setDidResume(didResume: boolean): SetDidResumeAction { return { type: SET_DID_RESUME, @@ -398,6 +416,7 @@ export const actions = { applyDonationBadge, clearWorkflow, internalAddDonationReceipt, + hydrateConfigCache, setDidResume, resumeWorkflow, submitDonation, @@ -417,6 +436,7 @@ export function getEmptyState(): DonationsStateType { didResumeWorkflowAtStartup: false, lastError: undefined, receipts: [], + configCache: undefined, }; } @@ -431,6 +451,13 @@ export function reducer( }; } + if (action.type === HYDRATE_CONFIG_CACHE) { + return { + ...state, + configCache: action.payload.configCache, + }; + } + if (action.type === SET_DID_RESUME) { return { ...state, diff --git a/ts/state/ducks/megaphones.preload.ts b/ts/state/ducks/megaphones.preload.ts index 6c3de1ed23..a6a6a6d606 100644 --- a/ts/state/ducks/megaphones.preload.ts +++ b/ts/state/ducks/megaphones.preload.ts @@ -105,7 +105,7 @@ function interactWithMegaphone( navActions.changeLocation({ tab: NavTab.Settings, details: { - page: SettingsPage.Donations, + page: SettingsPage.DonationsDonateFlow, }, }) ); diff --git a/ts/state/selectors/donations.std.ts b/ts/state/selectors/donations.std.ts new file mode 100644 index 0000000000..33ece04742 --- /dev/null +++ b/ts/state/selectors/donations.std.ts @@ -0,0 +1,21 @@ +// Copyright 2026 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { createSelector } from 'reselect'; +import type { ReadonlyDeep } from 'type-fest'; +import type { StateType } from '../reducer.preload.js'; +import type { DonationsStateType } from '../ducks/donations.preload.js'; +import type { OneTimeDonationHumanAmounts } from '../../types/Donations.std.js'; + +export const getDonationsState = ( + state: Readonly +): DonationsStateType => state.donations; + +export const getDonationConfigCache = createSelector( + getDonationsState, + ({ + configCache, + }: Readonly): + | ReadonlyDeep + | undefined => configCache +); diff --git a/ts/state/smart/PreferencesDonations.preload.tsx b/ts/state/smart/PreferencesDonations.preload.tsx index 209bec803b..47d4d93945 100644 --- a/ts/state/smart/PreferencesDonations.preload.tsx +++ b/ts/state/smart/PreferencesDonations.preload.tsx @@ -1,7 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { memo, useEffect, useState, useCallback } from 'react'; +import React, { memo, useEffect, useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import type { MutableRefObject } from 'react'; @@ -21,12 +21,11 @@ import { useConversationsActions } from '../ducks/conversations.preload.js'; import { generateDonationReceiptBlob } from '../../util/generateDonationReceipt.dom.js'; import { useToastActions } from '../ducks/toast.preload.js'; import { - getDonationHumanAmounts, getCachedSubscriptionConfiguration, + maybeHydrateDonationConfigCache, } from '../../util/subscriptionConfiguration.preload.js'; import { drop } from '../../util/drop.std.js'; import { saveAttachmentToDisk } from '../../util/migrations.preload.js'; -import type { OneTimeDonationHumanAmounts } from '../../types/Donations.std.js'; import { ONE_TIME_DONATION_CONFIG_ID, BOOST_ID, @@ -42,6 +41,10 @@ import { useBadgesActions } from '../ducks/badges.preload.js'; import { getNetworkIsOnline } from '../selectors/network.preload.js'; import { getItems } from '../selectors/items.dom.js'; import { isFeaturedEnabledSelector } from '../../util/isFeatureEnabled.dom.js'; +import { + getDonationConfigCache, + getDonationsState, +} from '../selectors/donations.std.js'; const log = createLogger('SmartPreferencesDonations'); @@ -55,12 +58,6 @@ export const SmartPreferencesDonations = memo( settingsLocation: SettingsLocation; setSettingsLocation: (settingsLocation: SettingsLocation) => void; }) { - const [validCurrencies, setValidCurrencies] = useState< - ReadonlyArray - >([]); - const [donationAmountsConfig, setDonationAmountsConfig] = - useState(); - const getPreferredBadge = useSelector(getPreferredBadgeSelector); const isOnline = useSelector(getNetworkIsOnline); @@ -68,7 +65,13 @@ export const SmartPreferencesDonations = memo( const items = useSelector(getItems); const theme = useSelector(getTheme); - const donationsState = useSelector((state: StateType) => state.donations); + const donationsState = useSelector(getDonationsState); + const donationAmountsConfig = useSelector(getDonationConfigCache); + const validCurrencies = useMemo( + () => (donationAmountsConfig ? Object.keys(donationAmountsConfig) : []), + [donationAmountsConfig] + ); + const { applyDonationBadge, clearWorkflow, @@ -123,13 +126,7 @@ export const SmartPreferencesDonations = memo( // Eagerly load donation config from API when entering Donations Home so the // Amount picker loads instantly useEffect(() => { - async function loadDonationAmounts() { - const amounts = await getDonationHumanAmounts(); - setDonationAmountsConfig(amounts); - const currencies = Object.keys(amounts); - setValidCurrencies(currencies); - } - drop(loadDonationAmounts()); + drop(maybeHydrateDonationConfigCache()); }, []); const currencyFromPhone = ourNumber diff --git a/ts/util/subscriptionConfiguration.preload.ts b/ts/util/subscriptionConfiguration.preload.ts index 205914cb0c..b027d31783 100644 --- a/ts/util/subscriptionConfiguration.preload.ts +++ b/ts/util/subscriptionConfiguration.preload.ts @@ -10,17 +10,35 @@ import { } from '../types/Donations.std.js'; import { HOUR } from './durations/index.std.js'; import { isInPast } from './timestamp.std.js'; +import { createLogger } from '../logging/log.std.js'; +import { TaskDeduplicator } from './TaskDeduplicator.std.js'; + +const log = createLogger('subscriptionConfiguration'); const SUBSCRIPTION_CONFIG_CACHE_TIME = HOUR; let cachedSubscriptionConfig: SubscriptionConfigurationResultType | undefined; let cachedSubscriptionConfigExpiresAt: number | undefined; -export async function getCachedSubscriptionConfiguration(): Promise { - if ( - cachedSubscriptionConfigExpiresAt != null && +function isCacheRefreshNeeded(): boolean { + return ( + cachedSubscriptionConfig == null || + cachedSubscriptionConfigExpiresAt == null || isInPast(cachedSubscriptionConfigExpiresAt) - ) { + ); +} + +export async function getCachedSubscriptionConfiguration(): Promise { + return getCachedSubscriptionConfigurationDedup.run(); +} + +const getCachedSubscriptionConfigurationDedup = new TaskDeduplicator( + 'getCachedSubscriptionConfiguration', + () => _getCachedSubscriptionConfiguration() +); + +export async function _getCachedSubscriptionConfiguration(): Promise { + if (isCacheRefreshNeeded()) { cachedSubscriptionConfig = undefined; } @@ -28,6 +46,7 @@ export async function getCachedSubscriptionConfiguration(): Promise { +export function getCachedSubscriptionConfigExpiresAt(): number | undefined { + return cachedSubscriptionConfigExpiresAt; +} + +export async function getCachedDonationHumanAmounts(): Promise { const { currencies } = await getCachedSubscriptionConfiguration(); // pickBy returns a Partial so we need to cast it return pickBy( @@ -47,3 +70,12 @@ export async function getDonationHumanAmounts(): Promise { + if (!isCacheRefreshNeeded()) { + return; + } + + const amounts = await getCachedDonationHumanAmounts(); + window.reduxActions.donations.hydrateConfigCache(amounts); +} diff --git a/ts/windows/main/preload_test.preload.ts b/ts/windows/main/preload_test.preload.ts index a14650dfcb..8f99af3d0f 100644 --- a/ts/windows/main/preload_test.preload.ts +++ b/ts/windows/main/preload_test.preload.ts @@ -138,6 +138,7 @@ window.testUtilities = { didResumeWorkflowAtStartup: false, lastError: undefined, receipts: [], + configCache: undefined, }, stickers: { installedPack: null,