Add a dialog for debugging feature flags to developer settings (#10988)
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
Reference in New Issue
Block a user