Add setting for opting in to beta features (#11123)
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import {MMKV} from '@bsky.app/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'
|
||||
|
||||
@@ -11,12 +14,14 @@ 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: async (key, value) => {
|
||||
setItem: (key, value) => {
|
||||
CACHE.set(key, value)
|
||||
},
|
||||
},
|
||||
@@ -44,15 +49,13 @@ export const features = new GrowthBook({
|
||||
* that case, we may see a flash of uncustomized content until the
|
||||
* initialization completes.
|
||||
*/
|
||||
export const init = new Promise<void>(async y => {
|
||||
const res = await features.init({timeout: TIMEOUT_INIT})
|
||||
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(),
|
||||
})
|
||||
}
|
||||
y()
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -68,6 +71,89 @@ export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
|
||||
})
|
||||
}
|
||||
|
||||
export function getFeatures() {
|
||||
return features.getFeatures()
|
||||
}
|
||||
|
||||
export function getFeatureDescription(feature: Features, i18n: I18n) {
|
||||
switch (feature) {
|
||||
case Features.PostThreadKnownLikersEnable:
|
||||
return {
|
||||
key: feature,
|
||||
name: i18n._(
|
||||
msg({
|
||||
message: 'Social proofing on posts',
|
||||
comment: 'Name for a feature flag',
|
||||
}),
|
||||
),
|
||||
description: i18n._(
|
||||
msg({
|
||||
message: 'Spot posts your friends and follows have liked.',
|
||||
comment: 'Description of a feature flag (Social proofing on posts)',
|
||||
}),
|
||||
),
|
||||
}
|
||||
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
|
||||
@@ -80,7 +166,7 @@ export function setAttributes({
|
||||
session,
|
||||
preferences,
|
||||
}: Metadata) {
|
||||
features.setAttributes({
|
||||
void features.setAttributes({
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
platform: base.platform,
|
||||
@@ -92,5 +178,6 @@ export function setAttributes({
|
||||
appLanguage: preferences?.appLanguage,
|
||||
contentLanguages: preferences?.contentLanguages,
|
||||
currentScreen: getNavigationMetadata()?.currentScreen,
|
||||
isBetaUser: base.isBetaUser,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* If a feature is in the beta program, be sure to add a localized description
|
||||
* for it via getFeatureDescription().
|
||||
*/
|
||||
export enum Features {
|
||||
// core flags
|
||||
IsBskyTeam = 'is_bsky_team',
|
||||
|
||||
+52
-3
@@ -1,4 +1,10 @@
|
||||
import {createContext, useContext, useMemo} from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
useSyncExternalStore,
|
||||
} from 'react'
|
||||
import {Platform} from 'react-native'
|
||||
import {type Result} from '@growthbook/growthbook-react'
|
||||
|
||||
@@ -26,7 +32,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
|
||||
import * as refParams from '#/analytics/misc/refParams'
|
||||
import * as env from '#/env'
|
||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||
import {device} from '#/storage'
|
||||
import {account, device} from '#/storage'
|
||||
|
||||
export * as utils from '#/analytics/utils'
|
||||
export const features = {init, refresh}
|
||||
@@ -120,6 +126,38 @@ Context.displayName = 'AnalyticsContext'
|
||||
*/
|
||||
export const setupDeviceId = getAndMigrateDeviceId()
|
||||
|
||||
/**
|
||||
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
||||
* writes from `BetaUserStorageSync` and the beta settings toggle.
|
||||
*
|
||||
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
||||
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
||||
* above the `<Fragment key={did}>` remount breaker, so on an account switch it
|
||||
* re-renders (with a new did) rather than remounting. `useStorage` would keep
|
||||
* serving the previous account's seeded value until a write happened to fire
|
||||
* its listener, leaking a beta account's flag into a non-beta account. Reading
|
||||
* via `useSyncExternalStore` re-evaluates `getSnapshot` every render, so the
|
||||
* value is always correct for the current did.
|
||||
*/
|
||||
function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
|
||||
const subscribe = useCallback(
|
||||
(onChange: () => void) => {
|
||||
if (!did) return () => {}
|
||||
const sub = account.addOnValueChangedListener(
|
||||
[did, 'isBetaUser'],
|
||||
onChange,
|
||||
)
|
||||
return () => sub.remove()
|
||||
},
|
||||
[did],
|
||||
)
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!did) return undefined
|
||||
return account.get([did, 'isBetaUser'])
|
||||
}, [did])
|
||||
return useSyncExternalStore(subscribe, getSnapshot)
|
||||
}
|
||||
|
||||
/**
|
||||
* Analytics context provider. Decorates the parent analytics context with
|
||||
* additional metadata. Nesting should be done carefully and sparingly.
|
||||
@@ -141,6 +179,16 @@ export function AnalyticsContext({
|
||||
const sessionId = useSessionId()
|
||||
const geolocation = useGeolocationServiceResponse()
|
||||
const parentContext = useContext(Context)
|
||||
/*
|
||||
* `isBetaUser` is account-specific, so it's cached per account. Read it
|
||||
* scoped to the did for this render's session (from the `metadata` prop when
|
||||
* set, otherwise inherited from the parent context). Without a did (e.g.
|
||||
* logged out, or the top-level context above the session provider) there's
|
||||
* no value, so beta-gated features are never evaluated for an ineligible or
|
||||
* absent account.
|
||||
*/
|
||||
const did = metadata?.session?.did ?? parentContext.metadata.session?.did
|
||||
const isBetaUser = useAccountIsBetaUser(did)
|
||||
const childContext = useMemo(() => {
|
||||
const combinedMetadata = {
|
||||
...parentContext.metadata,
|
||||
@@ -148,6 +196,7 @@ export function AnalyticsContext({
|
||||
base: {
|
||||
...parentContext.metadata.base,
|
||||
sessionId,
|
||||
isBetaUser,
|
||||
},
|
||||
geolocation,
|
||||
}
|
||||
@@ -166,7 +215,7 @@ export function AnalyticsContext({
|
||||
},
|
||||
}
|
||||
return context
|
||||
}, [sessionId, geolocation, parentContext, metadata])
|
||||
}, [parentContext, metadata, sessionId, isBetaUser, geolocation])
|
||||
return <Context.Provider value={childContext}>{children}</Context.Provider>
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export type BaseMetadata = {
|
||||
bundleDate: number
|
||||
referrerSrc: string
|
||||
referrerUrl: string
|
||||
isBetaUser?: boolean
|
||||
}
|
||||
|
||||
export type GeolocationMetadata = Geolocation
|
||||
@@ -66,7 +67,7 @@ export function getMetadataForLogger({
|
||||
base,
|
||||
geolocation,
|
||||
session,
|
||||
}: Metadata): Record<string, any> {
|
||||
}: Metadata): Record<string, unknown> {
|
||||
return {
|
||||
deviceId: base.deviceId,
|
||||
sessionId: base.sessionId,
|
||||
|
||||
Reference in New Issue
Block a user