Implement device-local featuregate overrides
This commit is contained in:
+15
-10
@@ -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]} />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user