From 3c5c11c0025e5d7ebaaa6ddade3191f522cf8a7f Mon Sep 17 00:00:00 2001 From: DS Boyce <260543580+ds-boyce@users.noreply.github.com> Date: Wed, 15 Jul 2026 06:06:54 -0700 Subject: [PATCH] Add setting for opting in to beta features (#11123) --- oxlint-suppressions.json | 16 -- package.json | 6 +- pnpm-lock.yaml | 84 +++---- src/App.tsx | 2 + src/App.web.tsx | 2 + src/Navigation.tsx | 9 + src/analytics/features/index.ts | 97 +++++++- src/analytics/features/types.ts | 4 + src/analytics/index.tsx | 55 ++++- src/analytics/metadata.ts | 3 +- src/lib/routes/types.ts | 1 + src/routes.ts | 1 + src/screens/Settings/AccountSettings.tsx | 29 +-- src/screens/Settings/BetaFeaturesSettings.tsx | 233 ++++++++++++++++++ src/screens/Settings/Settings.tsx | 9 + .../components/BetaFeaturesFeedbackDialog.tsx | 159 ++++++++++++ src/state/preferences/beta-user-sync.tsx | 37 +++ src/state/queries/preferences/const.ts | 1 + src/state/queries/preferences/index.ts | 15 ++ src/storage/schema.ts | 12 + 20 files changed, 685 insertions(+), 90 deletions(-) create mode 100644 src/screens/Settings/BetaFeaturesSettings.tsx create mode 100644 src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx create mode 100644 src/state/preferences/beta-user-sync.tsx diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 21dd9f702b..b5eb42488b 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -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 diff --git a/package.json b/package.json index 3dd3663a32..2542931936 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70e5e2ccf1..51468f2236 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/App.tsx b/src/App.tsx index 03481ce012..2af2146d92 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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}> + diff --git a/src/App.web.tsx b/src/App.web.tsx index aa8550bbc2..8070c4d333 100644 --- a/src/App.web.tsx +++ b/src/App.web.tsx @@ -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}> + diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 7bf45b41dc..76fa7187b2 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -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, }} /> + BetaFeaturesSettingsScreen} + options={{ + title: title(msg`Beta features`), + requireAuth: true, + }} + /> AutomationLabelSettingsScreen} diff --git a/src/analytics/features/index.ts b/src/analytics/features/index.ts index 7ec6f359a7..6afd2f91b4 100644 --- a/src/analytics/features/index.ts +++ b/src/analytics/features/index.ts @@ -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(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, }) } diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b890873a37..2e754d8f2e 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', diff --git a/src/analytics/index.tsx b/src/analytics/index.tsx index b40b722095..20f919f501 100644 --- a/src/analytics/index.tsx +++ b/src/analytics/index.tsx @@ -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 `` 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 {children} } diff --git a/src/analytics/metadata.ts b/src/analytics/metadata.ts index 9039c38686..7077f58254 100644 --- a/src/analytics/metadata.ts +++ b/src/analytics/metadata.ts @@ -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 { +}: Metadata): Record { return { deviceId: base.deviceId, sessionId: base.sessionId, diff --git a/src/lib/routes/types.ts b/src/lib/routes/types.ts index b197d85bef..6eb112a381 100644 --- a/src/lib/routes/types.ts +++ b/src/lib/routes/types.ts @@ -63,6 +63,7 @@ export type CommonNavigatorParams = { PreferencesExternalEmbeds: undefined AccessibilitySettings: undefined AppearanceSettings: undefined + BetaFeaturesSettings: undefined AccountSettings: undefined AutomationLabelSettings: undefined PrivacyAndSecuritySettings: undefined diff --git a/src/routes.ts b/src/routes.ts index 87b58e0d07..3e45f1b379 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -50,6 +50,7 @@ export const router = new Router({ 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', diff --git a/src/screens/Settings/AccountSettings.tsx b/src/screens/Settings/AccountSettings.tsx index ed72c78a9f..94f3f6c9df 100644 --- a/src/screens/Settings/AccountSettings.tsx +++ b/src/screens/Settings/AccountSettings.tsx @@ -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 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) { {currentAccount && !currentAccount.emailConfirmed && ( emailDialogControl.open({ id: EmailDialogScreenID.Verify, @@ -109,7 +107,7 @@ export function AccountSettingsScreen({}: Props) { )} emailDialogControl.open({ id: EmailDialogScreenID.Update, @@ -123,7 +121,7 @@ export function AccountSettingsScreen({}: Props) { changePasswordControl.open()}> @@ -132,8 +130,8 @@ export function AccountSettingsScreen({}: Props) { changeHandleControl.open()}> @@ -147,27 +145,27 @@ export function AccountSettingsScreen({}: Props) { Birthday birthdayControl.open()} /> + label={l`Automation label`}> Automation label {profile && ( - {isBotAccount(profile) ? _(msg`On`) : _(msg`Off`)} + {isBotAccount(profile) ? l`On` : l`Off`} )} exportCarControl.open()}> @@ -176,7 +174,7 @@ export function AccountSettingsScreen({}: Props) { deactivateAccountControl.open()} destructive> @@ -186,7 +184,7 @@ export function AccountSettingsScreen({}: Props) { deleteAccountControl.open()} destructive> @@ -197,7 +195,6 @@ export function AccountSettingsScreen({}: Props) { - diff --git a/src/screens/Settings/BetaFeaturesSettings.tsx b/src/screens/Settings/BetaFeaturesSettings.tsx new file mode 100644 index 0000000000..fa755faf0a --- /dev/null +++ b/src/screens/Settings/BetaFeaturesSettings.tsx @@ -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 ( + + + + + + Beta features + + + + + + + void onChange(value)}> + + + + Enable beta features + + + + Get early access to experimental features we’re testing. + + + + + + + + + + {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', + })} + + + + + {betaFeatures.length < 1 ? ( + + + + No beta features at the moment. + + + Check back later! + + + ) : ( + <> + + Current beta features + + + {betaFeatures.map(feature => ( + + + {feature.name} + + + {feature.description} + + + ))} + + + )} + + + + + feature.key)} + /> + + ) +} diff --git a/src/screens/Settings/Settings.tsx b/src/screens/Settings/Settings.tsx index 5fd53cde39..405f1136e6 100644 --- a/src/screens/Settings/Settings.tsx +++ b/src/screens/Settings/Settings.tsx @@ -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) { Languages + + + + Beta features + + void Linking.openURL(HELP_DESK_URL)} label={l`Help`} diff --git a/src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx b/src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx new file mode 100644 index 0000000000..1cca74d5ad --- /dev/null +++ b/src/screens/Settings/components/BetaFeaturesFeedbackDialog.tsx @@ -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 ( + + + + + + ) +} + +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 ( + + + + + {l`Share feedback`} + + + {l`We’d love to hear about your experience testing beta features!`} + + + + + {remaining} + + + setFeedback(value.slice(0, MAX_FEEDBACK_LENGTH)) + } + /> + + + + + + + + ) +} diff --git a/src/state/preferences/beta-user-sync.tsx b/src/state/preferences/beta-user-sync.tsx new file mode 100644 index 0000000000..f8cead4955 --- /dev/null +++ b/src/state/preferences/beta-user-sync.tsx @@ -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 +} diff --git a/src/state/queries/preferences/const.ts b/src/state/queries/preferences/const.ts index 02f7d62a87..74c86d1020 100644 --- a/src/state/queries/preferences/const.ts +++ b/src/state/queries/preferences/const.ts @@ -50,6 +50,7 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = { queuedNudges: [], activeProgressGuide: undefined, nuxs: [], + isBetaUser: undefined, }, postInteractionSettings: { threadgateAllowRules: undefined, diff --git a/src/state/queries/preferences/index.ts b/src/state/queries/preferences/index.ts index 816683062e..41adfc9a04 100644 --- a/src/state/queries/preferences/index.ts +++ b/src/state/queries/preferences/index.ts @@ -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() diff --git a/src/storage/schema.ts b/src/storage/schema.ts index add2e9a0a8..33c6bc0b56 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -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 }