From ee34009f1d1311d58e9bd91fc9b9becc831cb5d1 Mon Sep 17 00:00:00 2001 From: Jonathan Tzeng Date: Fri, 19 Jun 2026 16:05:38 -0700 Subject: [PATCH] Add remote enable/disable of spend providers via info server Consume a new spendInfo config from the info server, mirroring the exchangeInfo pattern. A generic disablePlugins NestedDisableMap keyed by providerId gates the gift-card market: an entire provider (Phaze or Bitrefill) or individual Phaze brands by productId can be disabled remotely. Reuses the NestedDisableMap cleaner from ExchangeInfoActions. --- CHANGELOG.md | 2 + src/__tests__/GiftCardInfoActions.test.ts | 60 ++++++++++ .../__snapshots__/RootReducer.test.ts.snap | 3 + src/actions/ExchangeInfoActions.ts | 2 +- src/actions/GiftCardInfoActions.ts | 60 ++++++++++ src/components/scenes/GiftCardMarketScene.tsx | 105 +++++++++++------- src/components/services/Services.tsx | 4 + src/reducers/GiftCardInfoReducer.ts | 22 ++++ src/reducers/uiReducer.ts | 4 + src/types/reduxActions.ts | 2 + 10 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 src/__tests__/GiftCardInfoActions.test.ts create mode 100644 src/actions/GiftCardInfoActions.ts create mode 100644 src/reducers/GiftCardInfoReducer.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index bf61de5bca8..12fe47fb7f1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased (develop) +- added: Remote enable/disable of gift card providers via the info server's giftCardInfo config, supporting whole-provider disabling for Phaze and Bitrefill and per-brand disabling for Phaze. + ## 4.49.0 (staging) - added: Monero wallet import support diff --git a/src/__tests__/GiftCardInfoActions.test.ts b/src/__tests__/GiftCardInfoActions.test.ts new file mode 100644 index 00000000000..5e99f12abe6 --- /dev/null +++ b/src/__tests__/GiftCardInfoActions.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from '@jest/globals' + +import type { NestedDisableMap } from '../actions/ExchangeInfoActions' +import { + asGiftCardInfo, + isGiftCardBrandDisabled, + isGiftCardProviderDisabled +} from '../actions/GiftCardInfoActions' + +describe('asGiftCardInfo cleaner', () => { + test('defaults to an empty disable map when disablePlugins is missing', () => { + expect(asGiftCardInfo({})).toEqual({ disablePlugins: {} }) + }) + + test('parses whole-provider and per-brand disable maps', () => { + const parsed = asGiftCardInfo({ + disablePlugins: { + bitrefill: true, + phaze: { '12345': true } + } + }) + expect(parsed.disablePlugins).toEqual({ + bitrefill: true, + phaze: { '12345': true } + }) + }) +}) + +describe('isGiftCardProviderDisabled', () => { + test('true only when the provider is disabled as a whole', () => { + expect(isGiftCardProviderDisabled({ bitrefill: true }, 'bitrefill')).toBe( + true + ) + expect(isGiftCardProviderDisabled({}, 'bitrefill')).toBe(false) + // A per-brand map disables brands, not the whole provider + expect(isGiftCardProviderDisabled({ phaze: { '1': true } }, 'phaze')).toBe( + false + ) + }) +}) + +describe('isGiftCardBrandDisabled', () => { + test('true when the whole provider is disabled', () => { + expect(isGiftCardBrandDisabled({ phaze: true }, 'phaze', '12345')).toBe( + true + ) + }) + + test('true only for the specific disabled brand', () => { + const disablePlugins: NestedDisableMap = { phaze: { '12345': true } } + expect(isGiftCardBrandDisabled(disablePlugins, 'phaze', '12345')).toBe(true) + expect(isGiftCardBrandDisabled(disablePlugins, 'phaze', '99999')).toBe( + false + ) + }) + + test('false when the provider is absent from the map', () => { + expect(isGiftCardBrandDisabled({}, 'phaze', '12345')).toBe(false) + }) +}) diff --git a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap index 8cd89f0cab1..a575e85bf19 100644 --- a/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap +++ b/src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap @@ -92,6 +92,9 @@ exports[`initialState 1`] = ` "fioAddressesLoading": false, "fioDomains": [], }, + "giftCardInfo": { + "disablePlugins": {}, + }, "notificationHeight": 0, "passwordReminder": { "lastLoginDate": -Infinity, diff --git a/src/actions/ExchangeInfoActions.ts b/src/actions/ExchangeInfoActions.ts index 1cdaca2c205..1466b39a756 100644 --- a/src/actions/ExchangeInfoActions.ts +++ b/src/actions/ExchangeInfoActions.ts @@ -28,7 +28,7 @@ export type DisablePluginMap = ReturnType export interface NestedDisableMap { [pluginId: string]: true | NestedDisableMap } -const asNestedDisableMap: Cleaner = asObject( +export const asNestedDisableMap: Cleaner = asObject( asEither(asTrue, raw => asNestedDisableMap(raw)) ) diff --git a/src/actions/GiftCardInfoActions.ts b/src/actions/GiftCardInfoActions.ts new file mode 100644 index 00000000000..64fc44ec6c2 --- /dev/null +++ b/src/actions/GiftCardInfoActions.ts @@ -0,0 +1,60 @@ +import { asMaybe, asObject } from 'cleaners' + +import type { ThunkAction } from '../types/reduxTypes' +import { infoServerData } from '../util/network' +import { + asNestedDisableMap, + type NestedDisableMap +} from './ExchangeInfoActions' + +// Remote enable/disable config for gift card providers, served by the info +// server as `giftCardInfo`. `disablePlugins` is a generic NestedDisableMap +// keyed by providerId, so it works for any present or future provider: +// { phaze: true } // disable the entire provider +// { phaze: { '12345': true } } // disable a single brand by productId +// { bitrefill: true } // webview provider: whole-provider only +export const asGiftCardInfo = asObject({ + disablePlugins: asMaybe(asNestedDisableMap, () => ({})) +}) + +export type GiftCardInfo = ReturnType + +export function updateGiftCardInfo(): ThunkAction> { + return async dispatch => { + try { + // `giftCardInfo` is a forward-compatible read: the field arrives once the + // edge-info-server dependency that defines it is published and bumped. + // Until then the rollup omits it and we fall back to an empty config. + const rollup = infoServerData.rollup as + | { giftCardInfo?: unknown } + | undefined + const data = asGiftCardInfo(rollup?.giftCardInfo ?? {}) + dispatch({ type: 'UPDATE_GIFT_CARD_INFO', data }) + } catch (e: unknown) { + const message = e instanceof Error ? e.message : String(e) + console.warn(`Failed to get info server giftCardInfo: ${message}`) + } + } +} + +/** True when the entire provider is remotely disabled. */ +export const isGiftCardProviderDisabled = ( + disablePlugins: NestedDisableMap, + pluginId: string +): boolean => disablePlugins[pluginId] === true + +/** + * True when a specific brand within a provider is remotely disabled — either + * because the whole provider is disabled, or because the brand is listed + * individually. + */ +export const isGiftCardBrandDisabled = ( + disablePlugins: NestedDisableMap, + pluginId: string, + brandId: string +): boolean => { + const providerNode = disablePlugins[pluginId] + if (providerNode === true) return true + if (providerNode == null) return false + return providerNode[brandId] === true +} diff --git a/src/components/scenes/GiftCardMarketScene.tsx b/src/components/scenes/GiftCardMarketScene.tsx index b34437fe8eb..54eec5f82cb 100644 --- a/src/components/scenes/GiftCardMarketScene.tsx +++ b/src/components/scenes/GiftCardMarketScene.tsx @@ -6,6 +6,10 @@ import LinearGradient from 'react-native-linear-gradient' import Animated from 'react-native-reanimated' import { showCountrySelectionModal } from '../../actions/CountryListActions' +import { + isGiftCardBrandDisabled, + isGiftCardProviderDisabled +} from '../../actions/GiftCardInfoActions' import { readSyncedSettings } from '../../actions/SettingsActions' import { EDGE_CONTENT_SERVER_URI } from '../../constants/CdnConstants' import { SCROLL_INDICATOR_INSET_FIX } from '../../constants/constantSettings' @@ -43,6 +47,12 @@ type ViewMode = 'grid' | 'list' // Internal constant for "All" category comparison - display uses lstrings.string_all const CATEGORY_ALL = 'All' +// Provider IDs used as keys in the info-server giftCardInfo.disablePlugins map. +// Phaze supports per-brand granularity (keyed by productId); Bitrefill is a +// webview, so only whole-provider disabling applies. +const PHAZE_PLUGIN_ID = 'phaze' +const BITREFILL_PLUGIN_ID = 'bitrefill' + /** * Formats a normalized category for display: * - Replaces dashes with " & " @@ -110,6 +120,11 @@ export const GiftCardMarketScene: React.FC = props => { const account = useSelector(state => state.core.account) const isConnected = useSelector(state => state.network.isConnected) + // Info-server remote enable/disable config for gift card providers + const giftCardDisablePlugins = useSelector( + state => state.ui.giftCardInfo.disablePlugins + ) + // Provider (requires API key configured) const phazeConfig = ENV.PLUGIN_API_KEYS?.phaze const { provider, isReady } = useGiftCardProvider({ @@ -136,19 +151,6 @@ export const GiftCardMarketScene: React.FC = props => { } return null }) - const [allCategories, setAllCategories] = React.useState(() => { - const cached = getCachedBrandsSync(countryCode) - if (cached != null) { - const categorySet = new Set() - for (const brand of cached) { - for (const category of brand.categories) { - categorySet.add(normalizeCategory(category)) - } - } - return Array.from(categorySet).sort() - } - return [] - }) // Search state const [searchText, setSearchText] = React.useState('') @@ -199,27 +201,12 @@ export const GiftCardMarketScene: React.FC = props => { [] ) - // Extract unique normalized categories from brands - const extractCategories = React.useCallback( - (brands: PhazeGiftCardBrand[]): string[] => { - const categorySet = new Set() - for (const brand of brands) { - for (const category of brand.categories) { - categorySet.add(normalizeCategory(category)) - } - } - return Array.from(categorySet).sort() - }, - [] - ) - // Helper to update UI state from brands const updateFromBrands = React.useCallback( (brands: PhazeGiftCardBrand[]): void => { setItems(mapBrandsToItems(brands)) - setAllCategories(extractCategories(brands)) }, - [extractCategories, mapBrandsToItems] + [mapBrandsToItems] ) // If the user changes country while on this scene, clear the current list so @@ -232,7 +219,6 @@ export const GiftCardMarketScene: React.FC = props => { prevCountryCodeRef.current !== countryCode ) { setItems(null) - setAllCategories([]) setSelectedCategory(CATEGORY_ALL) setSearchText('') setIsSearching(false) @@ -296,20 +282,52 @@ export const GiftCardMarketScene: React.FC = props => { } }, [apiBrands, updateFromBrands]) - // Build category list with "All" first, then alphabetized categories + // Remove phaze brands disabled by the info server, either because the whole + // phaze provider is disabled or because the brand's productId is listed. + const enabledItems = React.useMemo(() => { + if (items == null) return null + return items.filter( + item => + !isGiftCardBrandDisabled( + giftCardDisablePlugins, + PHAZE_PLUGIN_ID, + String(item.productId) + ) + ) + }, [items, giftCardDisablePlugins]) + + // Build the category list from the enabled items only, so a category whose + // brands are all disabled by the info server does not show a chip that leads + // to an empty grid. "All" comes first, then the categories alphabetized. const categoryList = React.useMemo(() => { const normalizedSet = new Set() - for (const cat of allCategories) { - normalizedSet.add(normalizeCategory(cat)) + if (enabledItems != null) { + for (const item of enabledItems) { + for (const category of item.categories) { + normalizedSet.add(normalizeCategory(category)) + } + } } return [CATEGORY_ALL, ...Array.from(normalizedSet).sort()] - }, [allCategories]) + }, [enabledItems]) + + // If the selected category is no longer available (e.g. all of its brands + // became disabled by the info server on a later refresh), fall back to "All" + // so the grid is not left stuck on an empty selection. + React.useEffect(() => { + if ( + selectedCategory !== CATEGORY_ALL && + !categoryList.includes(selectedCategory) + ) { + setSelectedCategory(CATEGORY_ALL) + } + }, [categoryList, selectedCategory]) // Filter items by search text and category const filteredItems = React.useMemo(() => { - if (items == null) return null + if (enabledItems == null) return null - let filtered = items + let filtered = enabledItems // Filter by category (unless "All" is selected, which shows all) if (selectedCategory !== CATEGORY_ALL) { @@ -327,7 +345,7 @@ export const GiftCardMarketScene: React.FC = props => { } return filtered - }, [items, searchText, selectedCategory]) + }, [enabledItems, searchText, selectedCategory]) const handleItemPress = useHandler((item: MarketItem) => { if (provider == null) return @@ -476,10 +494,17 @@ export const GiftCardMarketScene: React.FC = props => { ] ) - // Build list data: filtered items + Bitrefill option at end + // Build list data: filtered items + Bitrefill option at end (unless the + // Bitrefill provider is remotely disabled) const listData = React.useMemo(() => { - return [...(filteredItems ?? []), BITREFILL_ITEM] - }, [filteredItems]) + const base = filteredItems ?? [] + if ( + isGiftCardProviderDisabled(giftCardDisablePlugins, BITREFILL_PLUGIN_ID) + ) { + return base + } + return [...base, BITREFILL_ITEM] + }, [filteredItems, giftCardDisablePlugins]) return ( = props => { dispatch(updateExchangeInfo()).catch((error: unknown) => { console.warn(error) }) + dispatch(updateGiftCardInfo()).catch((error: unknown) => { + console.warn(error) + }) }, undefined, REFRESH_INFO_SERVER_MS diff --git a/src/reducers/GiftCardInfoReducer.ts b/src/reducers/GiftCardInfoReducer.ts new file mode 100644 index 00000000000..cca53895dd0 --- /dev/null +++ b/src/reducers/GiftCardInfoReducer.ts @@ -0,0 +1,22 @@ +import type { GiftCardInfo } from '../actions/GiftCardInfoActions' +import type { Action } from '../types/reduxTypes' + +export const initialState: GiftCardInfo = { + disablePlugins: {} +} + +export const giftCardInfo = ( + state: GiftCardInfo = initialState, + action: Action +): GiftCardInfo => { + switch (action.type) { + case 'UPDATE_GIFT_CARD_INFO': { + return { + ...state, + ...action.data + } + } + default: + return state + } +} diff --git a/src/reducers/uiReducer.ts b/src/reducers/uiReducer.ts index 38d75555ac7..50173f52d3f 100644 --- a/src/reducers/uiReducer.ts +++ b/src/reducers/uiReducer.ts @@ -1,9 +1,11 @@ import { combineReducers, type Reducer } from 'redux' import type { ExchangeInfo } from '../actions/ExchangeInfoActions' +import type { GiftCardInfo } from '../actions/GiftCardInfoActions' import type { Action } from '../types/reduxTypes' import { exchangeInfo } from './ExchangeInfoReducer' import { fio, type FioState } from './FioReducer' +import { giftCardInfo } from './GiftCardInfoReducer' import { passwordReminder, type PasswordReminderState @@ -16,6 +18,7 @@ import { settings, type SettingsState } from './scenes/SettingsReducer' export interface UiState { readonly exchangeInfo: ExchangeInfo + readonly giftCardInfo: GiftCardInfo readonly fio: FioState readonly fioAddress: FioAddressSceneState readonly passwordReminder: PasswordReminderState @@ -27,6 +30,7 @@ export interface UiState { const uiInner = combineReducers({ exchangeInfo, + giftCardInfo, fio, fioAddress, passwordReminder, diff --git a/src/types/reduxActions.ts b/src/types/reduxActions.ts index 724383183b0..c1a11ded1df 100644 --- a/src/types/reduxActions.ts +++ b/src/types/reduxActions.ts @@ -8,6 +8,7 @@ import type { import type { ExchangeInfo } from '../actions/ExchangeInfoActions' import type { GuiExchangeRates } from '../actions/ExchangeRateActions' +import type { GiftCardInfo } from '../actions/GiftCardInfoActions' import type { NotificationSettings } from '../actions/NotificationActions' import type { PasswordReminderTime, @@ -158,6 +159,7 @@ export type Action = data: { wallets: EdgeCurrencyWallet[] } } | { type: 'UPDATE_EXCHANGE_INFO'; data: ExchangeInfo } + | { type: 'UPDATE_GIFT_CARD_INFO'; data: GiftCardInfo } | { type: 'UPDATE_SORTED_WALLET_LIST'; data: WalletListItem[] } | { type: 'UPDATE_SHOW_PASSWORD_RECOVERY_REMINDER_MODAL'