Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/GiftCardInfoActions.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
3 changes: 3 additions & 0 deletions src/__tests__/reducers/__snapshots__/RootReducer.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ exports[`initialState 1`] = `
"fioAddressesLoading": false,
"fioDomains": [],
},
"giftCardInfo": {
"disablePlugins": {},
},
"notificationHeight": 0,
"passwordReminder": {
"lastLoginDate": -Infinity,
Expand Down
2 changes: 1 addition & 1 deletion src/actions/ExchangeInfoActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export type DisablePluginMap = ReturnType<typeof asDisablePluginsMap>
export interface NestedDisableMap {
[pluginId: string]: true | NestedDisableMap
}
const asNestedDisableMap: Cleaner<NestedDisableMap> = asObject(
export const asNestedDisableMap: Cleaner<NestedDisableMap> = asObject(
asEither(asTrue, raw => asNestedDisableMap(raw))
)

Expand Down
60 changes: 60 additions & 0 deletions src/actions/GiftCardInfoActions.ts
Original file line number Diff line number Diff line change
@@ -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<typeof asGiftCardInfo>

export function updateGiftCardInfo(): ThunkAction<Promise<void>> {
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
}
105 changes: 65 additions & 40 deletions src/components/scenes/GiftCardMarketScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 " & "
Expand Down Expand Up @@ -110,6 +120,11 @@ export const GiftCardMarketScene: React.FC<Props> = 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({
Expand All @@ -136,19 +151,6 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
}
return null
})
const [allCategories, setAllCategories] = React.useState<string[]>(() => {
const cached = getCachedBrandsSync(countryCode)
if (cached != null) {
const categorySet = new Set<string>()
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('')
Expand Down Expand Up @@ -199,27 +201,12 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
[]
)

// Extract unique normalized categories from brands
const extractCategories = React.useCallback(
(brands: PhazeGiftCardBrand[]): string[] => {
const categorySet = new Set<string>()
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
Expand All @@ -232,7 +219,6 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
prevCountryCodeRef.current !== countryCode
) {
setItems(null)
setAllCategories([])
setSelectedCategory(CATEGORY_ALL)
setSearchText('')
setIsSearching(false)
Expand Down Expand Up @@ -296,20 +282,52 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
}
}, [apiBrands, updateFromBrands])

// Build category list with "All" first, then alphabetized categories
// Remove phaze brands disabled by the info server, either because the whole
Comment thread
cursor[bot] marked this conversation as resolved.
// 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<string>()
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) {
Expand All @@ -327,7 +345,7 @@ export const GiftCardMarketScene: React.FC<Props> = props => {
}

return filtered
}, [items, searchText, selectedCategory])
}, [enabledItems, searchText, selectedCategory])
Comment thread
j0ntz marked this conversation as resolved.

const handleItemPress = useHandler((item: MarketItem) => {
if (provider == null) return
Expand Down Expand Up @@ -476,10 +494,17 @@ export const GiftCardMarketScene: React.FC<Props> = 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 (
<SceneWrapper
Expand Down
4 changes: 4 additions & 0 deletions src/components/services/Services.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { usePowerState } from 'react-native-device-info'
import { updateExchangeInfo } from '../../actions/ExchangeInfoActions'
import { refreshConnectedWallets } from '../../actions/FioActions'
import { refreshAllFioAddresses } from '../../actions/FioAddressActions'
import { updateGiftCardInfo } from '../../actions/GiftCardInfoActions'
import { registerNotificationsV2 } from '../../actions/NotificationActions'
import { trackAppUsageAfterUpgrade } from '../../actions/RequestReviewActions'
import { checkCompromisedKeys } from '../../actions/WalletActions'
Expand Down Expand Up @@ -145,6 +146,9 @@ export const Services: React.FC<Props> = props => {
dispatch(updateExchangeInfo()).catch((error: unknown) => {
console.warn(error)
})
dispatch(updateGiftCardInfo()).catch((error: unknown) => {
console.warn(error)
})
},
undefined,
REFRESH_INFO_SERVER_MS
Expand Down
22 changes: 22 additions & 0 deletions src/reducers/GiftCardInfoReducer.ts
Original file line number Diff line number Diff line change
@@ -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
}
}
Loading
Loading