Add a dialog for debugging feature flags to developer settings (#10988)

This commit is contained in:
DS Boyce
2026-07-29 16:49:32 -07:00
committed by GitHub
parent 91517af01c
commit 0be8b1dd93
3 changed files with 228 additions and 3 deletions
+2 -2
View File
@@ -40,6 +40,7 @@ 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,
})
/**
@@ -59,8 +60,7 @@ export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
})
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
* Refresh feature gates from GrowthBook.
*/
export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
await features.refreshFeatures({
+11 -1
View File
@@ -27,6 +27,7 @@ import {useOnboardingDispatch} from '#/state/shell'
import {useLoggedOutViewControls} from '#/state/shell/logged-out'
import {useCloseAllActiveElements} from '#/state/util'
import {UserAvatar} from '#/view/com/util/UserAvatar'
import {GrowthbookDialog} from '#/screens/Settings/components/GrowthbookDialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, platform, tokens, useBreakpoints, useTheme} from '#/alf'
import {AgeAssuranceDismissibleNotice} from '#/components/ageAssurance/AgeAssuranceDismissibleNotice'
@@ -398,6 +399,7 @@ function DevOptions() {
currentChannel,
} = useApplyPullRequestOTAUpdate()
const [actyNotifNudged, setActyNotifNudged] = useActivitySubscriptionsNudged()
const growthbookControl = useDialogControl()
const resetOnboarding = () => {
navigation.navigate('Home')
@@ -458,6 +460,14 @@ function DevOptions() {
<Trans>System log</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => growthbookControl.open()}
label={l`View GrowthBook information`}>
<SettingsList.ItemText>
<Trans>GrowthBook</Trans>
</SettingsList.ItemText>
</SettingsList.PressableItem>
<GrowthbookDialog control={growthbookControl} />
<SettingsList.PressableItem
onPress={() => navigation.navigate('Debug')}
label={l`Open storybook page`}>
@@ -474,7 +484,7 @@ function DevOptions() {
</SettingsList.PressableItem>
<SettingsList.PressableItem
onPress={() => deleteChatDeclarationRecord()}
label={l`Open storybook page`}>
label={l`Delete chat declaration record`}>
<SettingsList.ItemText>
<Trans>Delete chat declaration record</Trans>
</SettingsList.ItemText>
@@ -0,0 +1,215 @@
import {useState} from 'react'
import {Pressable, View} from 'react-native'
import * as Clipboard from 'expo-clipboard'
import {useModerationOpts} from '#/state/preferences/moderation-opts'
import {useProfileQuery} from '#/state/queries/profile'
import {useSession} from '#/state/session'
import {atoms as a, native, useTheme} from '#/alf'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {ArrowRotateClockwise_Stroke2_Corner0_Rounded as ArrowRotate} from '#/components/icons/ArrowRotate'
import {Loader} from '#/components/Loader'
import * as ProfileCard from '#/components/ProfileCard'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {Features, features, refresh} from '#/analytics/features'
export function GrowthbookDialog({
control,
}: {
control: Dialog.DialogControlProps
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<GrowthbookDialogInner />
</Dialog.Outer>
)
}
function GrowthbookDialogInner() {
const t = useTheme()
const [isRefreshing, setIsRefreshing] = useState(false)
return (
<Dialog.ScrollableInner
label="GrowthBook features"
header={
<Dialog.Header>
<Dialog.HeaderText>GrowthBook</Dialog.HeaderText>
<Dialog.Close />
</Dialog.Header>
}>
<View
style={[
a.gap_sm,
a.pb_lg,
a.mb_lg,
native(a.pt_lg),
a.border_b,
t.atoms.border_contrast_low,
]}>
<CurrentProfile />
</View>
<View style={[a.pb_lg, a.mb_lg, a.border_b, t.atoms.border_contrast_low]}>
<RefreshButton
isRefreshing={isRefreshing}
setIsRefreshing={setIsRefreshing}
/>
</View>
<View style={[a.gap_md]}>
{Object.entries(Features).map(([name, key]) => (
<FeatureRow
key={key}
name={name}
featureKey={key}
isRefreshing={isRefreshing}
/>
))}
</View>
</Dialog.ScrollableInner>
)
}
function RefreshButton({
isRefreshing,
setIsRefreshing,
}: {
isRefreshing: boolean
setIsRefreshing: React.Dispatch<React.SetStateAction<boolean>>
}) {
const onPress = () => {
setIsRefreshing(true)
refresh({strategy: 'prefer-fresh-gates'})
.then(() => {
Toast.show('Refreshed feature flags', {type: 'success'})
})
.catch(() => {
Toast.show('Failed to refresh feature flags', {type: 'error'})
})
.finally(() => {
setIsRefreshing(false)
})
}
return (
<Button
label="Refresh feature flags"
onPress={onPress}
disabled={isRefreshing}
color="secondary"
size="small">
<ButtonIcon icon={isRefreshing ? Loader : ArrowRotate} />
<ButtonText>Refresh feature flags</ButtonText>
</Button>
)
}
function FeatureRow({
name,
featureKey,
isRefreshing,
}: {
name: string
featureKey: string
isRefreshing: boolean
}) {
const t = useTheme()
const value = features.evalFeature(featureKey).value
const onPress = () => {
void Clipboard.setStringAsync(featureKey)
Toast.show('Copied feature flag key to clipboard', {type: 'success'})
}
return (
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy feature flag key"
accessibilityHint="Copies the feature flag key to the clipboard"
onPress={onPress}
style={[
a.flex_row,
a.align_center,
a.justify_between,
a.gap_md,
a.pb_md,
a.border_b,
t.atoms.border_contrast_low,
]}>
<View style={[a.flex_1]}>
<Text style={[a.text_sm, a.font_bold]}>{name}</Text>
<Text style={[a.text_xs, t.atoms.text_contrast_medium]}>
{featureKey}
</Text>
</View>
{isRefreshing ? <Loader size="sm" /> : <FeatureValue value={value} />}
</Pressable>
)
}
function CurrentProfile() {
const t = useTheme()
const {currentAccount} = useSession()
const moderationOpts = useModerationOpts()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
if (!currentAccount) {
return (
<Text style={[a.text_sm, t.atoms.text_contrast_medium]}>
No active account
</Text>
)
}
const onPressDid = () => {
void Clipboard.setStringAsync(currentAccount.did)
Toast.show('Copied did to clipboard', {type: 'success'})
}
return profile && moderationOpts ? (
<Pressable
accessibilityRole="button"
accessibilityLabel="Copy DID"
accessibilityHint="Copies your DID to the clipboard"
onPress={onPressDid}
hitSlop={native({top: 8, bottom: 8, left: 8, right: 8})}
style={[a.gap_sm]}>
<ProfileCard.Header>
<ProfileCard.Avatar
profile={profile}
moderationOpts={moderationOpts}
disabledPreview
/>
<ProfileCard.NameAndHandle
profile={profile}
moderationOpts={moderationOpts}
/>
</ProfileCard.Header>
</Pressable>
) : null
}
function FeatureValue({value}: {value: unknown}) {
const t = useTheme()
let label: string
let color: string
if (value === true) {
label = 'true'
color = t.palette.positive_500
} else if (value === false) {
label = 'false'
color = t.palette.negative_500
} else if (value === null || value === undefined) {
label = 'null'
color = t.palette.contrast_500
} else {
label = JSON.stringify(value)
color = t.palette.contrast_700
}
return <Text style={[a.text_sm, a.font_bold, {color}]}>{label}</Text>
}