Add setting for opting in to beta features (#11123)

This commit is contained in:
DS Boyce
2026-07-15 06:06:54 -07:00
committed by GitHub
parent d7f40b7e7f
commit 3c5c11c002
20 changed files with 685 additions and 90 deletions
+13 -16
View File
@@ -1,6 +1,4 @@
import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
@@ -36,7 +34,7 @@ import {ExportCarDialog} from './components/ExportCarDialog'
type Props = NativeStackScreenProps<CommonNavigatorParams, 'AccountSettings'>
export function AccountSettingsScreen({}: Props) {
const t = useTheme()
const {_} = useLingui()
const {t: l} = useLingui()
const {currentAccount} = useSession()
const {data: profile} = useProfileQuery({did: currentAccount?.did})
const emailDialogControl = useEmailDialogControl()
@@ -83,7 +81,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.Item>
{currentAccount && !currentAccount.emailConfirmed && (
<SettingsList.PressableItem
label={_(msg`Verify your email`)}
label={l`Verify your email`}
onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Verify,
@@ -109,7 +107,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
)}
<SettingsList.PressableItem
label={_(msg`Update email`)}
label={l`Update email`}
onPress={() =>
emailDialogControl.open({
id: EmailDialogScreenID.Update,
@@ -123,7 +121,7 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
<SettingsList.Divider />
<SettingsList.PressableItem
label={_(msg`Password`)}
label={l`Password`}
onPress={() => changePasswordControl.open()}>
<SettingsList.ItemIcon icon={LockIcon} />
<SettingsList.ItemText>
@@ -132,8 +130,8 @@ export function AccountSettingsScreen({}: Props) {
<SettingsList.Chevron />
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Handle`)}
accessibilityHint={_(msg`Opens change handle dialog`)}
label={l`Handle`}
accessibilityHint={l`Opens change handle dialog`}
onPress={() => changeHandleControl.open()}>
<SettingsList.ItemIcon icon={AtIcon} />
<SettingsList.ItemText>
@@ -147,27 +145,27 @@ export function AccountSettingsScreen({}: Props) {
<Trans>Birthday</Trans>
</SettingsList.ItemText>
<SettingsList.BadgeButton
label={_(msg`Edit`)}
label={l`Edit`}
onPress={() => birthdayControl.open()}
/>
</SettingsList.Item>
<AgeAssuranceAccountCard style={[a.px_xl, a.pt_xs, a.pb_md]} />
<SettingsList.LinkItem
to="/settings/automation-label"
label={_(msg`Automation label`)}>
label={l`Automation label`}>
<SettingsList.ItemIcon icon={RobotIcon} />
<SettingsList.ItemText>
<Trans>Automation label</Trans>
</SettingsList.ItemText>
{profile && (
<SettingsList.BadgeText>
{isBotAccount(profile) ? _(msg`On`) : _(msg`Off`)}
{isBotAccount(profile) ? l`On` : l`Off`}
</SettingsList.BadgeText>
)}
</SettingsList.LinkItem>
<SettingsList.Divider />
<SettingsList.PressableItem
label={_(msg`Export my data`)}
label={l`Export my data`}
onPress={() => exportCarControl.open()}>
<SettingsList.ItemIcon icon={CarIcon} />
<SettingsList.ItemText>
@@ -176,7 +174,7 @@ export function AccountSettingsScreen({}: Props) {
<SettingsList.Chevron />
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Deactivate account`)}
label={l`Deactivate account`}
onPress={() => deactivateAccountControl.open()}
destructive>
<SettingsList.ItemIcon icon={FreezeIcon} />
@@ -186,7 +184,7 @@ export function AccountSettingsScreen({}: Props) {
<SettingsList.Chevron />
</SettingsList.PressableItem>
<SettingsList.PressableItem
label={_(msg`Delete account`)}
label={l`Delete account`}
onPress={() => deleteAccountControl.open()}
destructive>
<SettingsList.ItemIcon icon={Trash_Stroke2_Corner2_Rounded} />
@@ -197,7 +195,6 @@ export function AccountSettingsScreen({}: Props) {
</SettingsList.PressableItem>
</SettingsList.Container>
</Layout.Content>
<BirthDateSettingsDialog control={birthdayControl} />
<ChangeHandleDialog control={changeHandleControl} />
<ChangePasswordDialog control={changePasswordControl} />
@@ -0,0 +1,233 @@
import {useEffect, useState} from 'react'
import {View} from 'react-native'
import {Trans, useLingui} from '@lingui/react/macro'
import {type NativeStackScreenProps} from '@react-navigation/native-stack'
import {type CommonNavigatorParams} from '#/lib/routes/types'
import {logger} from '#/logger'
import {
usePreferencesQuery,
useSetIsBetaUserMutation,
} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {BetaFeaturesFeedbackDialog} from '#/screens/Settings/components/BetaFeaturesFeedbackDialog'
import * as SettingsList from '#/screens/Settings/components/SettingsList'
import {atoms as a, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toggle from '#/components/forms/Toggle'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
import * as Layout from '#/components/Layout'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
import {features} from '#/analytics'
import {getTargetedFeatures} from '#/analytics/features'
import {IS_INTERNAL, IS_WEB} from '#/env'
import {account} from '#/storage'
type Props = NativeStackScreenProps<
CommonNavigatorParams,
'BetaFeaturesSettings'
>
export function BetaFeaturesSettingsScreen({}: Props) {
const t = useTheme()
const {t: l, i18n} = useLingui()
const {data: preferences} = usePreferencesQuery()
const {currentAccount} = useSession()
const {mutateAsync: setIsBetaUser} = useSetIsBetaUserMutation()
const isBetaUser = preferences?.bskyAppState?.isBetaUser ?? false
const [isPending, setIsPending] = useState(false)
const feedbackControl = Dialog.useDialogControl()
/*
* `getTargetedFeatures` reads the GrowthBook singleton synchronously, but
* gates arrive asynchronously (from `init` on mount, or `refresh` after a
* toggle). Hold the result in state and re-read whenever fresh gates land so
* the list reflects the latest evaluation instead of a stale render.
*/
const [betaFeatures, setBetaFeatures] = useState(() =>
getTargetedFeatures(i18n),
)
useEffect(() => {
let cancelled = false
void features.refresh({strategy: 'prefer-fresh-gates'}).then(() => {
if (!cancelled) setBetaFeatures(getTargetedFeatures(i18n))
})
return () => {
cancelled = true
}
}, [i18n])
const onChange = async (next: boolean) => {
try {
setIsPending(true)
await setIsBetaUser(next)
/*
* Cache the new value so analytics can set the `isBetaUser` GrowthBook
* attribute synchronously on the next boot, before beta-gated features
* are evaluated. Scoped per account, since `isBetaUser` is
* account-specific.
*/
if (currentAccount) {
account.set([currentAccount.did, 'isBetaUser'], next)
}
} catch (e) {
logger.error('Failed to toggle beta features', {safeMessage: e})
Toast.show(l`Something went wrong, please try again.`, {type: 'error'})
return
} finally {
setIsPending(false)
}
/*
* The toggle already succeeded; re-evaluate feature gates against the new
* attribute in-session as a best-effort follow-up. A failure here should
* not surface as a toggle error.
*/
try {
await features.refresh({strategy: 'prefer-fresh-gates'})
setBetaFeatures(getTargetedFeatures(i18n))
} catch {}
}
const onPressShareFeedback = () => {
feedbackControl.open()
}
const canSubmitFeedback =
!isPending && isBetaUser && (betaFeatures.length > 0 || IS_INTERNAL)
return (
<Layout.Screen>
<Layout.Header.Outer>
<Layout.Header.BackButton />
<Layout.Header.Content>
<Layout.Header.TitleText>
<Trans>Beta features</Trans>
</Layout.Header.TitleText>
</Layout.Header.Content>
<Layout.Header.Slot />
</Layout.Header.Outer>
<Layout.Content>
<SettingsList.Container>
<Toggle.Item
name="enable_beta_features"
label={l`Enable beta features`}
value={isBetaUser}
disabled={isPending}
onChange={value => void onChange(value)}>
<SettingsList.Item>
<View style={[a.flex_1, a.gap_2xs]}>
<SettingsList.ItemText style={[a.font_semi_bold]}>
<Trans>Enable beta features</Trans>
</SettingsList.ItemText>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_medium,
]}>
<Trans>
Get early access to experimental features were testing.
</Trans>
</Text>
</View>
<Toggle.Platform />
</SettingsList.Item>
</Toggle.Item>
<View style={[a.px_xl, a.gap_md]}>
<Admonition type="info">
{IS_WEB
? l({
message:
'Beta features may be unstable. Some changes may require reloading the app.',
context: 'web',
})
: l({
message:
'Beta features may be unstable. Some changes may require restarting the app.',
context: 'native',
})}
</Admonition>
<Button
disabled={!canSubmitFeedback}
label={l`Share feedback`}
size="small"
color="primary_subtle"
onPress={onPressShareFeedback}
style={[a.self_start, a.mt_xs, a.mb_xl]}>
<ButtonIcon icon={BubbleInfoIcon} />
<ButtonText>
<Trans>Share feedback</Trans>
</ButtonText>
</Button>
{betaFeatures.length < 1 ? (
<View style={[a.align_center, a.gap_xs, a.py_5xl]}>
<BeakerIcon
size="4xl"
fill={t.palette.contrast_100}
style={[a.mb_md]}
/>
<Text style={[a.text_md, a.font_semi_bold, a.leading_snug]}>
<Trans>No beta features at the moment.</Trans>
</Text>
<Text
style={[a.text_sm, t.atoms.text_contrast_high]}
emoji={false}>
<Trans>Check back later!</Trans>
</Text>
</View>
) : (
<>
<Text style={[a.text_md, a.font_semi_bold]}>
<Trans>Current beta features</Trans>
</Text>
<View style={[a.gap_sm]}>
{betaFeatures.map(feature => (
<View
key={feature.key}
style={[
a.p_md,
a.rounded_md,
a.border,
t.atoms.border_contrast_low,
]}>
<Text
style={[
a.text_md,
a.font_medium,
a.pb_2xs,
t.atoms.text_contrast_high,
]}>
{feature.name}
</Text>
<Text
style={[
a.text_sm,
a.leading_snug,
t.atoms.text_contrast_high,
]}>
{feature.description}
</Text>
</View>
))}
</View>
</>
)}
</View>
</SettingsList.Container>
</Layout.Content>
<BetaFeaturesFeedbackDialog
control={feedbackControl}
betaFeatureKeys={betaFeatures.map(feature => feature.key)}
/>
</Layout.Screen>
)
}
+9
View File
@@ -36,6 +36,7 @@ import {useIsFindContactsFeatureEnabledBasedOnGeolocation} from '#/components/co
import {useDialogControl} from '#/components/Dialog'
import {SwitchAccountDialog} from '#/components/dialogs/SwitchAccount'
import {Accessibility_Stroke2_Corner2_Rounded as AccessibilityIcon} from '#/components/icons/Accessibility'
import {Beaker_Stroke2_Corner2_Rounded as BeakerIcon} from '#/components/icons/Beaker'
import {Bell_Stroke2_Corner0_Rounded as NotificationIcon} from '#/components/icons/Bell'
import {BubbleInfo_Stroke2_Corner2_Rounded as BubbleInfoIcon} from '#/components/icons/BubbleInfo'
import {ChevronTop_Stroke2_Corner0_Rounded as ChevronUpIcon} from '#/components/icons/Chevron'
@@ -242,6 +243,14 @@ export function SettingsScreen({}: Props) {
<Trans>Languages</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.LinkItem
to="/settings/beta-features"
label={l`Beta features`}>
<SettingsList.ItemIcon icon={BeakerIcon} />
<SettingsList.ItemText>
<Trans>Beta features</Trans>
</SettingsList.ItemText>
</SettingsList.LinkItem>
<SettingsList.PressableItem
onPress={() => void Linking.openURL(HELP_DESK_URL)}
label={l`Help`}
@@ -0,0 +1,159 @@
import {useState} from 'react'
import {View} from 'react-native'
import {plural} from '@lingui/core/macro'
import {Trans, useLingui} from '@lingui/react/macro'
import {useMutation} from '@tanstack/react-query'
import {logger} from '#/logger'
import {Sentry} from '#/logger/sentry/lib'
import {atoms as a, useTheme, web} from '#/alf'
import {Button, ButtonText} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import * as Toast from '#/components/Toast'
import {Text} from '#/components/Typography'
const MAX_FEEDBACK_LENGTH = 300
export function BetaFeaturesFeedbackDialog({
control,
betaFeatureKeys,
}: {
control: Dialog.DialogControlProps
/**
* The keys of the beta feature gates that are currently active for this user,
* attached to the feedback report for context.
*/
betaFeatureKeys: string[]
}) {
return (
<Dialog.Outer control={control} nativeOptions={{preventExpansion: true}}>
<Dialog.Handle />
<BetaFeaturesFeedbackDialogInner betaFeatureKeys={betaFeatureKeys} />
<Dialog.Close />
</Dialog.Outer>
)
}
function BetaFeaturesFeedbackDialogInner({
betaFeatureKeys,
}: {
betaFeatureKeys: string[]
}) {
const t = useTheme()
const {t: l} = useLingui()
const control = Dialog.useDialogContext()
const [feedback, setFeedback] = useState('')
const {mutate: onSubmit, isPending} = useMutation({
mutationFn: () => {
/*
* `captureFeedback` is a no-op when the Sentry client is disabled (e.g. in
* dev, where `init` sets `enabled: false`). It returns synchronously
* without sending or throwing, so guard here to route to the error toast
* instead of falsely reporting success.
*/
const enabled = Sentry.getClient()?.getOptions().enabled ?? false
if (!enabled) {
throw new Error('Sentry is disabled; feedback was not sent')
}
Sentry.captureFeedback(
{
message: feedback.trim(),
},
{
captureContext: {
contexts: {
betaFeatures: {keys: betaFeatureKeys},
},
},
},
)
return Promise.resolve()
},
onSuccess: () => {
control.close(() => {
setFeedback('')
Toast.show(l`Thanks for your feedback!`)
})
},
onError: error => {
logger.error('Failed to send beta features feedback', {
safeMessage: error,
})
Toast.show(l`Something went wrong, please try again.`, {type: 'error'})
},
})
const remaining = MAX_FEEDBACK_LENGTH - feedback.length
const canSubmit = feedback.trim().length > 0 && !isPending
return (
<Dialog.ScrollableInner
label={l`Share feedback`}
style={web({maxWidth: 400})}>
<View style={[a.gap_lg]}>
<View style={[a.gap_xs]}>
<Text style={[a.text_2xl, a.font_semi_bold]}>
{l`Share feedback`}
</Text>
<Text style={[a.text_md, a.leading_snug, t.atoms.text_contrast_high]}>
{l`Wed love to hear about your experience testing beta features!`}
</Text>
</View>
<View style={[a.gap_xs]}>
<Text
accessibilityLabel={plural(remaining, {
one: '# character remaining',
other: '# characters remaining',
})}
accessibilityHint=""
style={[
a.text_sm,
a.text_right,
t.atoms.text_contrast_medium,
{fontVariant: ['tabular-nums']},
]}>
{remaining}
</Text>
<Dialog.Input
multiline
autoFocus
numberOfLines={5}
label={l`Share your thoughts…`}
value={feedback}
maxLength={MAX_FEEDBACK_LENGTH}
onChangeText={value =>
setFeedback(value.slice(0, MAX_FEEDBACK_LENGTH))
}
/>
</View>
<View style={[a.gap_sm]}>
<Button
label={l`Submit`}
size="large"
color="primary"
disabled={!canSubmit}
onPress={() => onSubmit()}>
<ButtonText>
<Trans>Submit feedback</Trans>
</ButtonText>
</Button>
<Button
label={l`Cancel`}
size="large"
color="secondary"
onPress={() =>
control.close(() => {
setFeedback('')
})
}>
<ButtonText>
<Trans>Cancel</Trans>
</ButtonText>
</Button>
</View>
</View>
</Dialog.ScrollableInner>
)
}