Add setting for opting in to beta features (#11123)
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
Generated
+38
-46
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,6 +63,7 @@ export type CommonNavigatorParams = {
|
||||
PreferencesExternalEmbeds: undefined
|
||||
AccessibilitySettings: undefined
|
||||
AppearanceSettings: undefined
|
||||
BetaFeaturesSettings: undefined
|
||||
AccountSettings: undefined
|
||||
AutomationLabelSettings: undefined
|
||||
PrivacyAndSecuritySettings: undefined
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 we’re 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>
|
||||
)
|
||||
}
|
||||
@@ -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`We’d 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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -50,6 +50,7 @@ export const DEFAULT_LOGGED_OUT_PREFERENCES: UsePreferencesQueryResponse = {
|
||||
queuedNudges: [],
|
||||
activeProgressGuide: undefined,
|
||||
nuxs: [],
|
||||
isBetaUser: undefined,
|
||||
},
|
||||
postInteractionSettings: {
|
||||
threadgateAllowRules: undefined,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user