Implement device-local featuregate overrides

This commit is contained in:
Paul Frazee
2025-03-25 23:11:07 -07:00
parent 1e65c43e87
commit c12102e6d0
4 changed files with 59 additions and 16 deletions
+15 -10
View File
@@ -1,17 +1,17 @@
import React from 'react' import React from 'react'
import {Platform} from 'react-native' import {Platform} from 'react-native'
import {AppState, AppStateStatus} from 'react-native' import {AppState, type AppStateStatus} from 'react-native'
import {Statsig, StatsigProvider} from 'statsig-react-native-expo' import {Statsig, StatsigProvider} from 'statsig-react-native-expo'
import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info'
import {logger} from '#/logger' import {logger} from '#/logger'
import {MetricEvents} from '#/logger/metrics' import {type MetricEvents} from '#/logger/metrics'
import {isWeb} from '#/platform/detection' import {isWeb} from '#/platform/detection'
import * as persisted from '#/state/persisted' import * as persisted from '#/state/persisted'
import {useSession} from '../../state/session' import {useSession} from '../../state/session'
import {timeout} from '../async/timeout' import {timeout} from '../async/timeout'
import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback'
import {Gate} from './gates' import {type Gate} from './gates'
const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV'
@@ -180,25 +180,26 @@ export function useGate(): (gateName: Gate, options?: GateOptions) => boolean {
} }
/** /**
* Debugging tool to override a gate. USE ONLY IN E2E TESTS! * Tool to override a gate on the local device
*/ */
export function useDangerousSetGate(): ( export function useSetLocalGateOverride(): (
gateName: Gate, gateName: Gate,
value: boolean, value: boolean,
) => void { ) => void {
const cache = React.useContext(GateCache) const cache = React.useContext(GateCache)
if (!cache) { if (!cache) {
throw Error( throw Error(
'useDangerousSetGate() cannot be called outside StatsigProvider.', 'useSetLocalOverride() cannot be called outside StatsigProvider.',
) )
} }
const dangerousSetGate = React.useCallback( const setGate = React.useCallback(
(gateName: Gate, value: boolean) => { (gateName: Gate, value: boolean) => {
cache.set(gateName, value) cache.set(gateName, value)
persisted.write('gateOverrides', Object.fromEntries(cache.entries()))
}, },
[cache], [cache],
) )
return dangerousSetGate return setGate
} }
function toStatsigUser(did: string | undefined): StatsigUser { function toStatsigUser(did: string | undefined): StatsigUser {
@@ -286,11 +287,11 @@ export function Provider({children}: {children: React.ReactNode}) {
// Have our own cache in front of Statsig. // Have our own cache in front of Statsig.
// This ensures the results remain stable until the active DID changes. // This ensures the results remain stable until the active DID changes.
const [gateCache, setGateCache] = React.useState(() => new Map()) const [gateCache, setGateCache] = React.useState(() => createGateCache())
const [prevDid, setPrevDid] = React.useState(did) const [prevDid, setPrevDid] = React.useState(did)
if (did !== prevDid) { if (did !== prevDid) {
setPrevDid(did) setPrevDid(did)
setGateCache(new Map()) setGateCache(createGateCache())
} }
// Periodically poll Statsig to get the current rule evaluations for all stored accounts. // Periodically poll Statsig to get the current rule evaluations for all stored accounts.
@@ -323,3 +324,7 @@ export function Provider({children}: {children: React.ReactNode}) {
</GateCache.Provider> </GateCache.Provider>
) )
} }
function createGateCache(): Map<string, boolean> {
return new Map(Object.entries(persisted.get('gateOverrides') || {}))
}
@@ -1,9 +1,11 @@
import {useState} from 'react' import {useState} from 'react'
import {View} from 'react-native' import {View} from 'react-native'
import * as Updates from 'expo-updates'
import {msg, Trans} from '@lingui/macro' import {msg, Trans} from '@lingui/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {type Gate, useGateDescriptions} from '#/lib/statsig/gates' import {type Gate, useGateDescriptions} from '#/lib/statsig/gates'
import {useGate, useSetLocalGateOverride} from '#/lib/statsig/statsig'
import {logger} from '#/logger' import {logger} from '#/logger'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button' import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -14,6 +16,7 @@ import {
EmojiSad_Stroke2_Corner0_Rounded as EmojiSad, EmojiSad_Stroke2_Corner0_Rounded as EmojiSad,
EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile, EmojiSmile_Stroke2_Corner0_Rounded as EmojiSmile,
} from '#/components/icons/Emoji' } from '#/components/icons/Emoji'
import {Loader} from '#/components/Loader'
import {P, Text} from '#/components/Typography' import {P, Text} from '#/components/Typography'
enum FeedbackToken { enum FeedbackToken {
@@ -37,10 +40,20 @@ export function FeatureGateDialog({
}) { }) {
const {_} = useLingui() const {_} = useLingui()
const t = useTheme() const t = useTheme()
const descriptions = useGateDescriptions() const descriptions = useGateDescriptions()
const gateApi = useGate()
const setGateApi = useSetLocalGateOverride()
const [originalEnabled] = useState(() => gateApi(gate))
const [enabled, setEnabled] = useState(originalEnabled)
const [isRestarting, setIsRestarting] = useState(false)
const desc = descriptions[gate] const desc = descriptions[gate]
const onToggleGate = v => {
setGateApi(gate, v)
setEnabled(v)
}
if (!desc) { if (!desc) {
return null return null
} }
@@ -66,16 +79,36 @@ export function FeatureGateDialog({
name="quoteposts" name="quoteposts"
type="checkbox" type="checkbox"
label={_(msg`Tap to toggle this experiment.`)} label={_(msg`Tap to toggle this experiment.`)}
value={true} value={enabled}
onChange={_v => {}} onChange={onToggleGate}
style={[a.justify_between]}> style={[a.justify_between]}>
<Text style={[t.atoms.text_contrast_high]}> <Text style={[t.atoms.text_contrast_high]}>
<Trans>Enable on this device</Trans> <Trans>Enabled on this device</Trans>
</Text> </Text>
<Toggle.Switch /> <Toggle.Switch />
</Toggle.Item> </Toggle.Item>
</View> </View>
{enabled !== originalEnabled ? (
<Button
variant="solid"
color="primary"
size="large"
onPress={() => {
setIsRestarting(true)
Updates.reloadAsync()
}}
label={_(msg`Restart to apply changes`)}
disabled={isRestarting}>
<ButtonText>
<Trans>Restart to apply changes</Trans>
</ButtonText>
{isRestarting ? (
<Loader size="sm" style={[{color: 'white'}]} />
) : null}
</Button>
) : null}
{desc.help ? ( {desc.help ? (
<> <>
<Divider style={[a.my_lg]} /> <Divider style={[a.my_lg]} />
+5 -2
View File
@@ -4,6 +4,7 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types' import {type CommonNavigatorParams} from '#/lib/routes/types'
import {type Gate, useGateDescriptions} from '#/lib/statsig/gates' import {type Gate, useGateDescriptions} from '#/lib/statsig/gates'
import {useGate} from '#/lib/statsig/statsig'
import * as SettingsList from '#/screens/Settings/components/SettingsList' import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useDialogControl} from '#/components/Dialog' import {useDialogControl} from '#/components/Dialog'
@@ -41,7 +42,7 @@ export function LabSettingsScreen({}: Props) {
</View> </View>
<SettingsList.Container> <SettingsList.Container>
{gates.map(gate => ( {gates.map(gate => (
<ExperimentButton key={gate} gate={gate} enabled={true} /> <ExperimentButton key={gate} gate={gate} />
))} ))}
</SettingsList.Container> </SettingsList.Container>
</Layout.Content> </Layout.Content>
@@ -49,10 +50,12 @@ export function LabSettingsScreen({}: Props) {
) )
} }
function ExperimentButton({gate, enabled}: {gate: Gate; enabled: boolean}) { function ExperimentButton({gate}: {gate: Gate}) {
const t = useTheme() const t = useTheme()
const ctrl = useDialogControl() const ctrl = useDialogControl()
const descriptions = useGateDescriptions() const descriptions = useGateDescriptions()
const gateApi = useGate()
const enabled = gateApi(gate)
return ( return (
<> <>
<SettingsList.Divider /> <SettingsList.Divider />
+2
View File
@@ -127,6 +127,7 @@ const schema = z.object({
mutedThreads: z.array(z.string()), mutedThreads: z.array(z.string()),
trendingDisabled: z.boolean().optional(), trendingDisabled: z.boolean().optional(),
trendingVideoDisabled: z.boolean().optional(), trendingVideoDisabled: z.boolean().optional(),
gateOverrides: z.object({}).catchall(z.boolean()).optional(),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
@@ -174,6 +175,7 @@ export const defaults: Schema = {
subtitlesEnabled: true, subtitlesEnabled: true,
trendingDisabled: false, trendingDisabled: false,
trendingVideoDisabled: false, trendingVideoDisabled: false,
gateOverrides: {},
} }
export function tryParse(rawData: string): Schema | undefined { export function tryParse(rawData: string): Schema | undefined {