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
-16
View File
@@ -93,17 +93,6 @@
"count": 2
}
},
"src/analytics/features/index.ts": {
"typescript/no-floating-promises": {
"count": 1
},
"typescript/no-misused-promises": {
"count": 1
},
"typescript/require-await": {
"count": 1
}
},
"src/analytics/identifiers/session.test.ts": {
"typescript/no-unsafe-call": {
"count": 9
@@ -112,11 +101,6 @@
"count": 9
}
},
"src/analytics/metadata.ts": {
"typescript/no-explicit-any": {
"count": 1
}
},
"src/analytics/metrics/client.test.ts": {
"typescript/no-explicit-any": {
"count": 1
+3 -3
View File
@@ -93,9 +93,9 @@
"prettier": "prettier --check ."
},
"dependencies": {
"@atproto/api": "0.20.25",
"@atproto/common-web": "0.5.3",
"@atproto/syntax": "0.6.4",
"@atproto/api": "0.20.28",
"@atproto/common-web": "0.5.6",
"@atproto/syntax": "0.7.2",
"@bitdrift/react-native": "^0.6.8",
"@braintree/sanitize-url": "^6.0.2",
"@bsky.app/alf": "^0.1.14",
+38 -46
View File
@@ -242,14 +242,14 @@ importers:
.:
dependencies:
'@atproto/api':
specifier: 0.20.25
version: 0.20.25
specifier: 0.20.28
version: 0.20.28
'@atproto/common-web':
specifier: 0.5.3
version: 0.5.3
specifier: 0.5.6
version: 0.5.6
'@atproto/syntax':
specifier: 0.6.4
version: 0.6.4
specifier: 0.7.2
version: 0.7.2
'@bitdrift/react-native':
specifier: ^0.6.8
version: 0.6.14(react-native@0.81.5(patch_hash=2656ac6deb71b92a4df4af4593d13ace6a5740936432e5e7e8ef32bc3cd05194)(@babel/core@7.29.0)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -862,32 +862,32 @@ packages:
graphql:
optional: true
'@atproto/api@0.20.25':
resolution: {integrity: sha512-PTwt6X0U45C9vikr8NRi0Qzcep9VqJet52HtfcxgG9R7ofRHxqLVPzfVuN/f2xOaYM6H5IG/Nk1E9kXZcI0mSQ==}
'@atproto/api@0.20.28':
resolution: {integrity: sha512-/Rvk8zt9mtRi9tlMD2Qg+NG2lMj3B0HDjmfswR5724pH7GsOEMDDHwleVlmOBSrgACIyVSa5tdCDJ+R+SEhwww==}
engines: {node: '>=22'}
'@atproto/common-web@0.5.3':
resolution: {integrity: sha512-FkMhOcNv1y7r5984+zXB+uMN4zJ4QFLEbZrYyQo6bNCf/Kfav/pEHXXENlaZEOweq2NYXf+tr2giMssBuM9u5A==}
'@atproto/common-web@0.5.6':
resolution: {integrity: sha512-5Y4MIK9dpkJPiKiE6u7iEHitxj+g3aAU2GfGL686JlKE2zDKD4y18BJb+uVek6nXQKb5XOdNVvw+7BHarpn8Fw==}
engines: {node: '>=22'}
'@atproto/lex-data@0.1.4':
resolution: {integrity: sha512-f9U95sk0zUtxHktvK59peU+Shd0cIUYV68p//GS7sfdkAxUIeaspvX6CY+Quv9Oa4aozmsXI52SjaNn1wuU8RQ==}
'@atproto/lex-data@0.1.5':
resolution: {integrity: sha512-TEM6GHuYpNm4O90LjNgbYq1Gmcr875S+BHrDxkg4PB5w/nlsz4HlbkGQG/WP/xbIV5O8TF5nNHDpCZ5ezTRrFA==}
engines: {node: '>=22'}
'@atproto/lex-json@0.1.3':
resolution: {integrity: sha512-Ch2w9bCLFOwWINFFxpZo6DBW7+ZxkehciU3P8N94gSyENLe3/5WWXHdHvkiH7EXZrpI7fKY8nVWn4GIL0ttqxw==}
'@atproto/lex-json@0.1.4':
resolution: {integrity: sha512-ENR2cWkVrES+UL6TovbCRdX9BJOyHHJUS8jYx3Lxp3j4vEphjn/u+DW7bWloST2O8ID2OuVlt6+28ftNEEjmQQ==}
engines: {node: '>=22'}
'@atproto/lexicon@0.7.4':
resolution: {integrity: sha512-ulk4RGwMBp4vbkTOZcIwZJeTEPlj3PImOnx9TqxSxMDnBdySxarpd0Aprg7+iAvGyKTsMcrYHpeoLukZaquk0A==}
'@atproto/lexicon@0.7.7':
resolution: {integrity: sha512-92VH2oEsJdrIVNy7WY8rGn99ANNVglyUffoN7GJc0mxKi+fXN5iVlJ2cOyTfYABhr2ldSiptZWXEPEdBaIR9/A==}
engines: {node: '>=22'}
'@atproto/syntax@0.6.4':
resolution: {integrity: sha512-ELgpShRGMF65cvLXcoMCZFL0HBzy67yz/Nlhox3yOtTh120B09KjjvZYXhMysVog3nUJqv2EW5rf1VRYCeM/hw==}
'@atproto/syntax@0.7.2':
resolution: {integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==}
engines: {node: '>=22'}
'@atproto/xrpc@0.8.3':
resolution: {integrity: sha512-0gUGN71+bSqV3PI5U4cQ4fdnw4nI0eD6czHDQ6jB7hMYBwNcJpwAyfA9W+C1G1wayIvP0SIap/M0H/pSKDWFIQ==}
'@atproto/xrpc@0.8.6':
resolution: {integrity: sha512-yVfKrlwZBBm44Ft9jDvHcTCwQ1ElqSlzXHWhT/KcqE+u24EAhWVFosfABfGbUByB/PmN8eLJ06FsWo5pUQcMNQ==}
engines: {node: '>=22'}
'@babel/code-frame@7.10.4':
@@ -8662,9 +8662,6 @@ packages:
uc.micro@2.1.0:
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
uint8arrays@5.1.1:
resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==}
unbox-primitive@1.1.0:
resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==}
engines: {node: '>= 0.4'}
@@ -9122,51 +9119,50 @@ snapshots:
'@0no-co/graphql.web@1.2.0': {}
'@atproto/api@0.20.25':
'@atproto/api@0.20.28':
dependencies:
'@atproto/common-web': 0.5.3
'@atproto/lexicon': 0.7.4
'@atproto/syntax': 0.6.4
'@atproto/xrpc': 0.8.3
'@atproto/common-web': 0.5.6
'@atproto/lexicon': 0.7.7
'@atproto/syntax': 0.7.2
'@atproto/xrpc': 0.8.6
await-lock: 3.0.0
multiformats: 13.4.2
tlds: 1.261.0
zod: 3.25.76
'@atproto/common-web@0.5.3':
'@atproto/common-web@0.5.6':
dependencies:
'@atproto/lex-data': 0.1.4
'@atproto/lex-json': 0.1.3
'@atproto/syntax': 0.6.4
'@atproto/lex-data': 0.1.5
'@atproto/lex-json': 0.1.4
'@atproto/syntax': 0.7.2
zod: 3.25.76
'@atproto/lex-data@0.1.4':
'@atproto/lex-data@0.1.5':
dependencies:
multiformats: 13.4.2
tslib: 2.8.1
uint8arrays: 5.1.1
unicode-segmenter: 0.14.5
'@atproto/lex-json@0.1.3':
'@atproto/lex-json@0.1.4':
dependencies:
'@atproto/lex-data': 0.1.4
'@atproto/lex-data': 0.1.5
tslib: 2.8.1
'@atproto/lexicon@0.7.4':
'@atproto/lexicon@0.7.7':
dependencies:
'@atproto/common-web': 0.5.3
'@atproto/syntax': 0.6.4
'@atproto/common-web': 0.5.6
'@atproto/syntax': 0.7.2
multiformats: 13.4.2
zod: 3.25.76
'@atproto/syntax@0.6.4':
'@atproto/syntax@0.7.2':
dependencies:
iso-datestring-validator: 2.2.2
tslib: 2.8.1
'@atproto/xrpc@0.8.3':
'@atproto/xrpc@0.8.6':
dependencies:
'@atproto/lexicon': 0.7.4
'@atproto/lexicon': 0.7.7
zod: 3.25.76
'@babel/code-frame@7.10.4':
@@ -18114,10 +18110,6 @@ snapshots:
uc.micro@2.1.0: {}
uint8arrays@5.1.1:
dependencies:
multiformats: 13.4.2
unbox-primitive@1.1.0:
dependencies:
call-bound: 1.0.4
+2
View File
@@ -33,6 +33,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
@@ -152,6 +153,7 @@ function InnerApp() {
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<BetaUserStorageSync />
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
+2
View File
@@ -26,6 +26,7 @@ import {Provider as HomeBadgeProvider} from '#/state/home-badge'
import {MessagesProvider} from '#/state/messages'
import {init as initPersistedState} from '#/state/persisted'
import {Provider as PrefsStateProvider} from '#/state/preferences'
import {BetaUserStorageSync} from '#/state/preferences/beta-user-sync'
import {Provider as LabelDefsProvider} from '#/state/preferences/label-defs'
import {Provider as ModerationOptsProvider} from '#/state/preferences/moderation-opts'
import {Provider as UnreadNotifsProvider} from '#/state/queries/notifications/unread'
@@ -132,6 +133,7 @@ function InnerApp() {
key={currentAccount?.did}>
<AnalyticsFeaturesContext>
<QueryProvider currentDid={currentAccount?.did}>
<BetaUserStorageSync />
<PolicyUpdateOverlayProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
+9
View File
@@ -108,6 +108,7 @@ import {AppearanceSettingsScreen} from '#/screens/Settings/AppearanceSettings'
import {AppIconSettingsScreen} from '#/screens/Settings/AppIconSettings'
import {AppPasswordsScreen} from '#/screens/Settings/AppPasswords'
import {AutomationLabelSettingsScreen} from '#/screens/Settings/AutomationLabelSettings'
import {BetaFeaturesSettingsScreen} from '#/screens/Settings/BetaFeaturesSettings'
import {ContentAndMediaSettingsScreen} from '#/screens/Settings/ContentAndMediaSettings'
import {ExternalMediaPreferencesScreen} from '#/screens/Settings/ExternalMediaPreferences'
import {FindContactsSettingsScreen} from '#/screens/Settings/FindContactsSettings'
@@ -405,6 +406,14 @@ function commonScreens(Stack: typeof Flat, unreadCountLabel?: string) {
requireAuth: true,
}}
/>
<Stack.Screen
name="BetaFeaturesSettings"
getComponent={() => BetaFeaturesSettingsScreen}
options={{
title: title(msg`Beta features`),
requireAuth: true,
}}
/>
<Stack.Screen
name="AutomationLabelSettings"
getComponent={() => AutomationLabelSettingsScreen}
+92 -5
View File
@@ -1,8 +1,11 @@
import {MMKV} from '@bsky.app/react-native-mmkv'
import {setPolyfills} from '@growthbook/growthbook'
import {GrowthBook} from '@growthbook/growthbook-react'
import {type I18n} from '@lingui/core'
import {msg} from '@lingui/core/macro'
import {Logger} from '#/logger'
import {Features} from '#/analytics/features/types'
import {getNavigationMetadata, type Metadata} from '#/analytics/metadata'
import * as env from '#/env'
@@ -11,12 +14,14 @@ export {Features} from '#/analytics/features/types'
const logger = Logger.create(Logger.Context.Growthbook)
const CACHE = new MMKV({id: 'bsky_features_cache'})
const BETA_USER_ATTRIBUTE = 'isBetaUser'
setPolyfills({
localStorage: {
getItem: key => {
return CACHE.getString(key) ?? null
},
setItem: async (key, value) => {
setItem: (key, value) => {
CACHE.set(key, value)
},
},
@@ -44,15 +49,13 @@ export const features = new GrowthBook({
* that case, we may see a flash of uncustomized content until the
* initialization completes.
*/
export const init = new Promise<void>(async y => {
const res = await features.init({timeout: TIMEOUT_INIT})
export const init = features.init({timeout: TIMEOUT_INIT}).then(res => {
if (!res.success) {
logger.warn('GrowthBook initialization failed or timed out', {
source: res.source,
safeMessage: res.error?.toString(),
})
}
y()
})
/**
@@ -68,6 +71,89 @@ export async function refresh({strategy}: {strategy: FeatureFetchStrategy}) {
})
}
export function getFeatures() {
return features.getFeatures()
}
export function getFeatureDescription(feature: Features, i18n: I18n) {
switch (feature) {
case Features.PostThreadKnownLikersEnable:
return {
key: feature,
name: i18n._(
msg({
message: 'Social proofing on posts',
comment: 'Name for a feature flag',
}),
),
description: i18n._(
msg({
message: 'Spot posts your friends and follows have liked.',
comment: 'Description of a feature flag (Social proofing on posts)',
}),
),
}
default:
return null
}
}
/**
* Walks a GrowthBook condition tree to determine whether it targets the given
* attribute. Conditions can nest via the logical operators `$and`, `$or`,
* `$nor` (arrays of sub-conditions) and `$not` (a single sub-condition), so a
* flat scan of the top-level keys would miss e.g.
* `{$and: [{isBetaUser: true}, ...]}`. Dot-notation access (e.g.
* `isBetaUser.foo`) counts as targeting the attribute as well.
*/
function conditionTargetsAttribute(
condition: unknown,
attribute: string,
): boolean {
if (!condition || typeof condition !== 'object') return false
for (const [key, value] of Object.entries(condition)) {
if (key === attribute || key.startsWith(`${attribute}.`)) return true
if (key === '$and' || key === '$or' || key === '$nor') {
if (
Array.isArray(value) &&
value.some(sub => conditionTargetsAttribute(sub, attribute))
) {
return true
}
} else if (key === '$not') {
if (conditionTargetsAttribute(value, attribute)) return true
}
}
return false
}
export function getTargetedFeatures(i18n: I18n) {
const allFeatures = features.getFeatures()
const targetedFeatures: {key: Features; name: string; description: string}[] =
[]
for (const [featureKey, feature] of Object.entries(allFeatures)) {
// Check if the feature contains any rules
if (!feature.rules) continue
// Determine if any rule targets the beta user attribute
const hasTargeting = feature.rules.some(rule =>
conditionTargetsAttribute(rule.condition, BETA_USER_ATTRIBUTE),
)
if (hasTargeting) {
const featureName = getFeatureDescription(featureKey as Features, i18n)
if (featureName) {
targetedFeatures.push(featureName)
}
}
}
return targetedFeatures
}
/**
* Converts our metadata into GrowthBook attributes and sets them. GrowthBook
* attributes are manually configured in the GrowthBook dashboard. So these
@@ -80,7 +166,7 @@ export function setAttributes({
session,
preferences,
}: Metadata) {
features.setAttributes({
void features.setAttributes({
deviceId: base.deviceId,
sessionId: base.sessionId,
platform: base.platform,
@@ -92,5 +178,6 @@ export function setAttributes({
appLanguage: preferences?.appLanguage,
contentLanguages: preferences?.contentLanguages,
currentScreen: getNavigationMetadata()?.currentScreen,
isBetaUser: base.isBetaUser,
})
}
+4
View File
@@ -1,3 +1,7 @@
/**
* If a feature is in the beta program, be sure to add a localized description
* for it via getFeatureDescription().
*/
export enum Features {
// core flags
IsBskyTeam = 'is_bsky_team',
+52 -3
View File
@@ -1,4 +1,10 @@
import {createContext, useContext, useMemo} from 'react'
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
} from 'react'
import {Platform} from 'react-native'
import {type Result} from '@growthbook/growthbook-react'
@@ -26,7 +32,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
import * as refParams from '#/analytics/misc/refParams'
import * as env from '#/env'
import {useGeolocationServiceResponse} from '#/geolocation/service'
import {device} from '#/storage'
import {account, device} from '#/storage'
export * as utils from '#/analytics/utils'
export const features = {init, refresh}
@@ -120,6 +126,38 @@ Context.displayName = 'AnalyticsContext'
*/
export const setupDeviceId = getAndMigrateDeviceId()
/**
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
* writes from `BetaUserStorageSync` and the beta settings toggle.
*
* This deliberately does not use `useStorage`, whose `useState` seeds once and
* only updates via the change listener. The consuming `AnalyticsContext` lives
* above the `<Fragment key={did}>` remount breaker, so on an account switch it
* re-renders (with a new did) rather than remounting. `useStorage` would keep
* serving the previous account's seeded value until a write happened to fire
* its listener, leaking a beta account's flag into a non-beta account. Reading
* via `useSyncExternalStore` re-evaluates `getSnapshot` every render, so the
* value is always correct for the current did.
*/
function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
const subscribe = useCallback(
(onChange: () => void) => {
if (!did) return () => {}
const sub = account.addOnValueChangedListener(
[did, 'isBetaUser'],
onChange,
)
return () => sub.remove()
},
[did],
)
const getSnapshot = useCallback(() => {
if (!did) return undefined
return account.get([did, 'isBetaUser'])
}, [did])
return useSyncExternalStore(subscribe, getSnapshot)
}
/**
* Analytics context provider. Decorates the parent analytics context with
* additional metadata. Nesting should be done carefully and sparingly.
@@ -141,6 +179,16 @@ export function AnalyticsContext({
const sessionId = useSessionId()
const geolocation = useGeolocationServiceResponse()
const parentContext = useContext(Context)
/*
* `isBetaUser` is account-specific, so it's cached per account. Read it
* scoped to the did for this render's session (from the `metadata` prop when
* set, otherwise inherited from the parent context). Without a did (e.g.
* logged out, or the top-level context above the session provider) there's
* no value, so beta-gated features are never evaluated for an ineligible or
* absent account.
*/
const did = metadata?.session?.did ?? parentContext.metadata.session?.did
const isBetaUser = useAccountIsBetaUser(did)
const childContext = useMemo(() => {
const combinedMetadata = {
...parentContext.metadata,
@@ -148,6 +196,7 @@ export function AnalyticsContext({
base: {
...parentContext.metadata.base,
sessionId,
isBetaUser,
},
geolocation,
}
@@ -166,7 +215,7 @@ export function AnalyticsContext({
},
}
return context
}, [sessionId, geolocation, parentContext, metadata])
}, [parentContext, metadata, sessionId, isBetaUser, geolocation])
return <Context.Provider value={childContext}>{children}</Context.Provider>
}
+2 -1
View File
@@ -9,6 +9,7 @@ export type BaseMetadata = {
bundleDate: number
referrerSrc: string
referrerUrl: string
isBetaUser?: boolean
}
export type GeolocationMetadata = Geolocation
@@ -66,7 +67,7 @@ export function getMetadataForLogger({
base,
geolocation,
session,
}: Metadata): Record<string, any> {
}: Metadata): Record<string, unknown> {
return {
deviceId: base.deviceId,
sessionId: base.sessionId,
+1
View File
@@ -63,6 +63,7 @@ export type CommonNavigatorParams = {
PreferencesExternalEmbeds: undefined
AccessibilitySettings: undefined
AppearanceSettings: undefined
BetaFeaturesSettings: undefined
AccountSettings: undefined
AutomationLabelSettings: undefined
PrivacyAndSecuritySettings: undefined
+1
View File
@@ -50,6 +50,7 @@ export const router = new Router<AllNavigatableRoutes>({
PreferencesExternalEmbeds: '/settings/external-embeds',
AccessibilitySettings: '/settings/accessibility',
AppearanceSettings: '/settings/appearance',
BetaFeaturesSettings: '/settings/beta-features',
SavedFeeds: '/settings/saved-feeds',
AccountSettings: '/settings/account',
AutomationLabelSettings: '/settings/automation-label',
+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>
)
}
+37
View File
@@ -0,0 +1,37 @@
import {useEffect} from 'react'
import {usePreferencesQuery} from '#/state/queries/preferences'
import {useSession} from '#/state/session'
import {account} from '#/storage'
/**
* Caches `bskyAppState.isBetaUser` from preferences into synchronous device
* storage so analytics can read it at init (before beta-gated features are
* evaluated). Scoped per account, since `isBetaUser` is account-specific:
* a global cache would let one account's value leak into another after a
* switch, until the new account's preferences loaded. Must be mounted below
* `QueryProvider`, since the analytics providers that consume the cached value
* sit above it.
*
* Lives outside `#/analytics` so that module never imports the preferences
* query, which would create a circular import.
*/
export function BetaUserStorageSync() {
const {currentAccount} = useSession()
const did = currentAccount?.did
const {data: preferences} = usePreferencesQuery()
const isBetaUser = preferences?.bskyAppState?.isBetaUser
useEffect(() => {
if (!did) return
if (isBetaUser === undefined) return
/*
* Guard against a redundant write on every warm boot. Writing triggers the
* storage change listener, which re-renders the analytics subtree.
*/
if (account.get([did, 'isBetaUser']) === isBetaUser) return
account.set([did, 'isBetaUser'], isBetaUser)
}, [did, isBetaUser])
return null
}
+1
View File
@@ -50,6 +50,7 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
queuedNudges: [],
activeProgressGuide: undefined,
nuxs: [],
isBetaUser: undefined,
},
postInteractionSettings: {
threadgateAllowRules: undefined,
+15
View File
@@ -438,6 +438,21 @@ export function useSetActiveProgressGuideMutation() {
})
}
export function useSetIsBetaUserMutation() {
const queryClient = useQueryClient()
const agent = useAgent()
return useMutation({
mutationFn: async (isBetaUser: boolean) => {
await agent.setIsBetaUser(isBetaUser)
// triggers a refetch
await queryClient.invalidateQueries({
queryKey: preferencesQueryKey,
})
},
})
}
export function useSetVerificationPrefsMutation() {
const ax = useAnalytics()
const queryClient = useQueryClient()
+12
View File
@@ -91,4 +91,16 @@ export type Account = {
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
*/
recentGifs?: Gif[]
/**
* Cached from preferences (`bskyAppState.isBetaUser`) so the GrowthBook
* `isBetaUser` attribute can be set synchronously at analytics init, before
* beta-gated features (e.g. SearchV2Enable) are first evaluated. Written back
* when preferences load.
*
* Scoped per account, since `isBetaUser` is account-specific preference data.
* Reading it globally would let a beta account's value leak into a non-beta
* account after a switch, until that account's preferences loaded.
*/
isBetaUser?: boolean
}