a9c502c8cf
Co-authored-by: Claude <noreply@anthropic.com>
186 lines
5.5 KiB
TypeScript
186 lines
5.5 KiB
TypeScript
import {MMKV} from 'react-native-mmkv'
|
|
import {setPolyfills} from '@growthbook/growthbook'
|
|
import {GrowthBook} from '@growthbook/growthbook-react'
|
|
import {type I18n} from '@lingui/core'
|
|
import {msg} from '@lingui/core/macro'
|
|
|
|
import {Logger} from '#/logger'
|
|
import {Features} from '#/analytics/features/types'
|
|
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
|
|
import * as env from '#/env'
|
|
|
|
export {Features} from '#/analytics/features/types'
|
|
|
|
const logger = Logger.create(Logger.Context.Growthbook)
|
|
const CACHE = new MMKV({id: 'bsky_features_cache'})
|
|
|
|
const BETA_USER_ATTRIBUTE = 'isBetaUser'
|
|
|
|
setPolyfills({
|
|
localStorage: {
|
|
getItem: key => {
|
|
return CACHE.getString(key) ?? null
|
|
},
|
|
setItem: (key, value) => {
|
|
CACHE.set(key, value)
|
|
},
|
|
},
|
|
})
|
|
|
|
/**
|
|
* We vary the amount of time we wait for GrowthBook to fetch feature
|
|
* gates based on the strategy specified.
|
|
*/
|
|
export type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
|
|
|
|
const TIMEOUT_INIT = 2000 // TODO should base on p99 or something
|
|
const TIMEOUT_PREFER_LOW_LATENCY = 250
|
|
const TIMEOUT_PREFER_FRESH_GATES = 1500
|
|
|
|
export const features = new GrowthBook({
|
|
apiHost: env.GROWTHBOOK_API_HOST,
|
|
clientKey: env.GROWTHBOOK_CLIENT_KEY,
|
|
enableDevMode: env.IS_INTERNAL,
|
|
})
|
|
|
|
/**
|
|
* Initializer promise that must be awaited before using the GrowthBook
|
|
* instance or rendering the `AnalyticsFeaturesContext`. Note: this may not be
|
|
* fully initialized if it takes longer than `TIMEOUT_INIT` to initialize. In
|
|
* that case, we may see a flash of uncustomized content until the
|
|
* initialization completes.
|
|
*/
|
|
export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
|
|
if (!res.success) {
|
|
logger.warn('GrowthBook initialization failed or timed out', {
|
|
source: res.source,
|
|
safeMessage: res.error?.toString(),
|
|
})
|
|
}
|
|
})
|
|
|
|
/**
|
|
* Refresh feature gates from GrowthBook.
|
|
*/
|
|
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
|
await features.refreshFeatures({
|
|
timeout:
|
|
strategy === 'prefer-low-latency'
|
|
? TIMEOUT_PREFER_LOW_LATENCY
|
|
: TIMEOUT_PREFER_FRESH_GATES,
|
|
})
|
|
}
|
|
|
|
export function getFeatures() {
|
|
return features.getFeatures()
|
|
}
|
|
|
|
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
|
switch (feature) {
|
|
case Features.CanonicalPostNumberingEnable:
|
|
return {
|
|
key: feature,
|
|
name: i18n._(
|
|
msg({
|
|
message: 'Thread numbering',
|
|
comment:
|
|
'Name for a feature flag (See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.)',
|
|
}),
|
|
),
|
|
description: i18n._(
|
|
msg({
|
|
message:
|
|
'See numbered badges (1/3, 2/3, etc.) on posts in a thread by the same author.',
|
|
comment: 'Description of a feature flag (Thread numbering)',
|
|
}),
|
|
),
|
|
}
|
|
default:
|
|
return null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Walks a GrowthBook condition tree to determine whether it targets the given
|
|
* attribute. Conditions can nest via the logical operators `$and`, `$or`,
|
|
* `$nor` (arrays of sub-conditions) and `$not` (a single sub-condition), so a
|
|
* flat scan of the top-level keys would miss e.g.
|
|
* `{$and: [{isBetaUser: true}, ...]}`. Dot-notation access (e.g.
|
|
* `isBetaUser.foo`) counts as targeting the attribute as well.
|
|
*/
|
|
function conditionTargetsAttribute(
|
|
condition: unknown,
|
|
attribute: string,
|
|
): boolean {
|
|
if (!condition || typeof condition !== 'object') return false
|
|
|
|
for (const [key, value] of Object.entries(condition)) {
|
|
if (key === attribute || key.startsWith(`${attribute}.`)) return true
|
|
|
|
if (key === '$and' || key === '$or' || key === '$nor') {
|
|
if (
|
|
Array.isArray(value) &&
|
|
value.some(sub => conditionTargetsAttribute(sub, attribute))
|
|
) {
|
|
return true
|
|
}
|
|
} else if (key === '$not') {
|
|
if (conditionTargetsAttribute(value, attribute)) return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
export function getTargetedFeatures(i18n: I18n) {
|
|
const allFeatures = features.getFeatures()
|
|
const targetedFeatures: {key: Features; name: string; description: string}[] =
|
|
[]
|
|
for (const [featureKey, feature] of Object.entries(allFeatures)) {
|
|
// Check if the feature contains any rules
|
|
if (!feature.rules) continue
|
|
|
|
// Determine if any rule targets the beta user attribute
|
|
const hasTargeting = feature.rules.some(rule =>
|
|
conditionTargetsAttribute(rule.condition, BETA_USER_ATTRIBUTE),
|
|
)
|
|
|
|
if (hasTargeting) {
|
|
const featureName = getFeatureDescription(featureKey as Features, i18n)
|
|
if (featureName) {
|
|
targetedFeatures.push(featureName)
|
|
}
|
|
}
|
|
}
|
|
|
|
return targetedFeatures
|
|
}
|
|
|
|
/**
|
|
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
|
|
* attributes are manually configured in the GrowthBook dashboard. So these
|
|
* values need to match exactly. Therefore, let's add them here manually to and
|
|
* not spread them to avoid mistakes.
|
|
*/
|
|
export function setAttributes({
|
|
base,
|
|
geolocation,
|
|
session,
|
|
preferences,
|
|
}: Metadata) {
|
|
void features.setAttributes({
|
|
deviceId: base.deviceId,
|
|
sessionId: base.sessionId,
|
|
platform: base.platform,
|
|
appVersion: base.appVersion,
|
|
countryCode: geolocation.countryCode,
|
|
regionCode: geolocation.regionCode,
|
|
did: session?.did,
|
|
isBskyPds: session?.isBskyPds,
|
|
appLanguage: preferences?.appLanguage,
|
|
contentLanguages: preferences?.contentLanguages,
|
|
currentScreen: getNavigationMetadata()?.currentScreen,
|
|
isBetaUser: base.isBetaUser,
|
|
})
|
|
}
|