Clean up growthbook code, integrate into init and sessions

This commit is contained in:
Eric Bailey
2026-01-20 11:03:26 -06:00
parent c005e02713
commit 443141d3dd
11 changed files with 355 additions and 172 deletions
+61 -58
View File
@@ -17,11 +17,15 @@ import * as Sentry from '@sentry/react-native'
import {KeyboardControllerProvider} from '#/lib/hooks/useEnableKeyboardController'
import {Provider as HideBottomBarBorderProvider} from '#/lib/hooks/useHideBottomBarBorder'
import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider, tryFetchGates} from '#/lib/statsig/statsig'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {s} from '#/lib/styles'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger, Provider as LoggingProvider} from '#/logger'
import {logger} from '#/logger'
import {
initializer as growthbookInitializer,
Provider as GrowthBookProvider,
} from '#/logger/growthbook/context'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -111,10 +115,9 @@ function InnerApp() {
useEffect(() => {
async function onLaunch(account?: SessionAccount) {
try {
await growthbookInitializer
if (account) {
await resumeSession(account)
} else {
await tryFetchGates(undefined, 'prefer-fresh-gates')
}
} catch (e) {
logger.error(`session: resume failed`, {message: e})
@@ -144,57 +147,59 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<GrowthBookProvider>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<ProgressGuideProvider>
<ServiceAccountManager>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<GestureHandlerRootView
style={s.h100pct}>
<GlobalGestureEventsProvider>
<IntentDialogProvider>
<TestCtrls />
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</GlobalGestureEventsProvider>
</GestureHandlerRootView>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceAccountManager>
</ProgressGuideProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</GrowthBookProvider>
</React.Fragment>
</VideoVolumeProvider>
</Splash>
@@ -238,9 +243,7 @@ function App() {
<StarterPackProvider>
<SafeAreaProvider
initialMetrics={initialWindowMetrics}>
<LoggingProvider>
<InnerApp />
</LoggingProvider>
<InnerApp />
</SafeAreaProvider>
</StarterPackProvider>
</BottomSheetProvider>
+59 -54
View File
@@ -12,8 +12,11 @@ import {QueryProvider} from '#/lib/react-query'
import {Provider as StatsigProvider} from '#/lib/statsig/statsig'
import {ThemeProvider} from '#/lib/ThemeContext'
import I18nProvider from '#/locale/i18nProvider'
import {logger, Provider as LoggingProvider} from '#/logger'
import {initializer as growthbookInitializer} from '#/logger/growthbook/context'
import {logger} from '#/logger'
import {
initializer as growthbookInitializer,
Provider as GrowthBookProvider,
} from '#/logger/growthbook/context'
import {Provider as A11yProvider} from '#/state/a11y'
import {Provider as MutedThreadsProvider} from '#/state/cache/thread-mutes'
import {Provider as DialogStateProvider} from '#/state/dialogs'
@@ -56,8 +59,10 @@ import {Provider as PortalProvider} from '#/components/Portal'
import {Provider as ActiveVideoProvider} from '#/components/Post/Embed/VideoEmbed/ActiveVideoWebContext'
import {Provider as VideoVolumeProvider} from '#/components/Post/Embed/VideoEmbed/VideoVolumeContext'
import {ToastOutlet} from '#/components/Toast'
import {Provider as AgeAssuranceV2Provider} from '#/ageAssurance'
import {prefetchAgeAssuranceConfig} from '#/ageAssurance'
import {
prefetchAgeAssuranceConfig,
Provider as AgeAssuranceV2Provider,
} from '#/ageAssurance'
import {
prefetchLiveEvents,
Provider as LiveEventsProvider,
@@ -121,53 +126,55 @@ function InnerApp() {
<React.Fragment
// Resets the entire tree below when it changes:
key={currentAccount?.did}>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
<GrowthBookProvider>
<QueryProvider currentDid={currentAccount?.did}>
<PolicyUpdateOverlayProvider>
<StatsigProvider>
<LiveEventsProvider>
<AgeAssuranceV2Provider>
<ComposerProvider>
<MessagesProvider>
{/* LabelDefsProvider MUST come before ModerationOptsProvider */}
<LabelDefsProvider>
<ModerationOptsProvider>
<LoggedOutViewProvider>
<SelectedFeedProvider>
<HiddenRepliesProvider>
<HomeBadgeProvider>
<UnreadNotifsProvider>
<BackgroundNotificationPreferencesProvider>
<MutedThreadsProvider>
<SafeAreaProvider>
<ProgressGuideProvider>
<ServiceConfigProvider>
<EmailVerificationProvider>
<HideBottomBarBorderProvider>
<IntentDialogProvider>
<Shell />
<ToastOutlet />
</IntentDialogProvider>
</HideBottomBarBorderProvider>
</EmailVerificationProvider>
</ServiceConfigProvider>
</ProgressGuideProvider>
</SafeAreaProvider>
</MutedThreadsProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</HomeBadgeProvider>
</HiddenRepliesProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>
</ModerationOptsProvider>
</LabelDefsProvider>
</MessagesProvider>
</ComposerProvider>
</AgeAssuranceV2Provider>
</LiveEventsProvider>
</StatsigProvider>
</PolicyUpdateOverlayProvider>
</QueryProvider>
</GrowthBookProvider>
</React.Fragment>
</ActiveVideoProvider>
</VideoVolumeProvider>
@@ -207,9 +214,7 @@ function App() {
<LightboxStateProvider>
<PortalProvider>
<StarterPackProvider>
<LoggingProvider>
<InnerApp />
</LoggingProvider>
<InnerApp />
</StarterPackProvider>
</PortalProvider>
</LightboxStateProvider>
@@ -4,6 +4,7 @@ import {t} from '@lingui/macro'
import {DISCOVER_DEBUG_DIDS} from '#/lib/constants'
import {useGate} from '#/lib/statsig/statsig'
import {logEvent} from '#/logger/growthbook/context'
import {useSession} from '#/state/session'
import {atoms as a, useTheme} from '#/alf'
import * as Toast from '#/components/Toast'
@@ -32,6 +33,7 @@ export function DiscoverDebug({
style={[a.absolute, {zIndex: 1000, maxWidth: 65, bottom: -4}, a.left_0]}
onPress={e => {
e.stopPropagation()
logEvent('discover_debug:copy_feed_context', {feedContext})
Clipboard.setStringAsync(feedContext)
Toast.show(t`Copied to clipboard`)
}}>
+4 -3
View File
@@ -87,13 +87,14 @@ export const CHAT_PROXY_DID: Did =
/**
* Growthbook API host
*/
export const GROWTHBOOK_API_HOST: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST
export const GROWTHBOOK_API_HOST: string | undefined =
process.env.EXPO_PUBLIC_GROWTHBOOK_API_HOST
/**
* Growthbook client key
*/
export const GROWTHBOOK_CLIENT_KEY: string | undefined = process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY
export const GROWTHBOOK_CLIENT_KEY: string =
process.env.EXPO_PUBLIC_GROWTHBOOK_CLIENT_KEY || 'sdk-7gkUkGy9wguUjyFe'
/**
* Sentry DSN for telemetry
+165 -43
View File
@@ -1,23 +1,57 @@
import {useMemo, useEffect} from 'react'
import {useCallback, useEffect} from 'react'
import {Platform} from 'react-native'
import {growthbookTrackingPlugin} from '@growthbook/growthbook/plugins'
import {GrowthBook, GrowthBookProvider} from '@growthbook/growthbook-react'
import {BSKY_SERVICE} from '#/lib/constants'
import {
GrowthBook,
GrowthBookProvider,
useFeatureIsOn,
} from '@growthbook/growthbook-react'
import * as env from '#/env'
import {useSession, SessionAccount} from '#/state/session'
import {useGeolocation} from '#/geolocation'
import * as persisted from '#/state/persisted'
getAndMigrateStableId,
getSessionId,
getStableId,
getStableIdOrThrow,
} from '#/logger/growthbook/identifiers'
import * as referrer from '#/logger/growthbook/util/referrer'
import * as persisted from '#/state/persisted'
import {type SessionAccount, useSession} from '#/state/session'
import * as env from '#/env'
import {type Geolocation, useGeolocation} from '#/geolocation'
type DefaultAttributes = {
const TIMEOUT_INIT = 500 // TODO should base on p99 or something
const TIMEOUT_PREFER_LOW_LATENCY = 250
const TIMEOUT_PREFER_FRESH_GATES = 1500
/**
* We vary the amount of time we wait for GrowthBook to fetch feature
* gates based on the strategy specified.
*
* TODO examples
*/
type FeatureFetchStrategy = 'prefer-low-latency' | 'prefer-fresh-gates'
/**
* These are fields that are handled specially by GrowthBook
*/
type GrowthBookDefaultAttributes = {
/** Special GrowthBook field */
device_id: string
/** Special GrowthBook field */
session_id: string
}
/**
* These are user fields that are handled specially by GrowthBook
*/
type GrowthBookDefaultUserAttributes = {
/** Special GrowthBook field */
user_id: string
}
type DefaultAttributes = GrowthBookDefaultAttributes & {
/** Custom field provided by our Geolocation context */
country: string
}
type UserAttributes = {
id: string
pds: string | undefined
type UserAttributes = GrowthBookDefaultUserAttributes & {
// do not use `id`, GrowthBook will think it's the same as `device_id`
did: string
isBskyPds: boolean
platform: string
appVersion: string
bundleIdentifier: string
@@ -27,71 +61,158 @@ type UserAttributes = {
appLanguage: string
contentLanguages: string[]
}
type Attributes = DefaultAttributes & UserAttributes
/**
* We cache the geolocation outside of React to use when setting
* default attributes outside React, such as during `refresh()`.
*/
let unsafeGeolocation: Geolocation | null = null
const gb = new GrowthBook({
apiHost: env.GROWTHBOOK_API_HOST,
clientKey: env.GROWTHBOOK_CLIENT_KEY,
plugins: [growthbookTrackingPlugin()],
trackingCallback: (experiment, result) => {
// TODO
console.log('Experiment Viewed', {
experimentId: experiment.key,
variationId: result.key,
})
gb.logEvent('Experiment Viewed', {
experimentId: experiment.key,
variationId: result.key,
})
},
attributes: {
device_id: getStableId() || 'unset',
session_id: getSessionId(),
} satisfies GrowthBookDefaultAttributes,
})
export const initializer = gb.init({
timeout: 1e3,
/**
* Initializer promise that must be awaited before using the GrowthBook
* instance or rendering the `Provider`. Note: this may not be fully
* initialized if it takes longer than `TIMEOUT_INIT` to initialize. In that
* case, we may see a flash of uncustomized content until the initialization
* completes.
*/
export const initializer = new Promise<void>(async y => {
/*
* This _must_ happen first to ensure continuity of the device ID from
* StatSig to GrowthBook
*/
const id = await getAndMigrateStableId()
const attr: GrowthBookDefaultAttributes = {
device_id: id,
session_id: getSessionId(),
}
gb.setAttributes(attr)
await gb.init({timeout: TIMEOUT_INIT})
y()
})
export function useGate(gate: string): boolean {
return useFeatureIsOn(gate)
/**
* Refresh feature gates from GrowthBook. Updates attributes based on the
* provided account, if any.
*/
export async function refresh({
account,
strategy,
}: {
account?: SessionAccount
strategy: FeatureFetchStrategy
}) {
setAttributesForAccount(account)
await gb.refreshFeatures({
timeout:
strategy === 'prefer-low-latency'
? TIMEOUT_PREFER_LOW_LATENCY
: TIMEOUT_PREFER_FRESH_GATES,
})
}
/**
* Log a custom event to our backend, using GrowthBook's event logging system.
*/
export function logEvent(eventName: string, metadata?: Record<string, any>) {
gb.logEvent(eventName, metadata)
}
/**
* Hook to check if a feature gate is enabled
*/
export function useGate() {
return useCallback((gate: string): boolean => {
return gb.isOn(gate)
}, [])
}
/**
* Main provider for GrowthBook and feature flag context. Should be rendered
* _after_ `initializer` is complete, and _after_ the Session provider.
*/
export function Provider({children}: {children: React.ReactNode}) {
const geo = useGeolocation()
const {currentAccount} = useSession()
const defaultAttributes = useMemo<DefaultAttributes>(
() => ({
country: geo.countryCode || 'unknown',
}),
[geo],
)
/**
* Decorate existing attributes with any new default attributes
*/
useEffect(() => {
// cache outside react
unsafeGeolocation = geo
const attr = {
...gb.getAttributes(),
...defaultAttributes,
...getDefaultAttributes(),
}
gb.setAttributes(attr)
console.debug(`update attributes`, {attributes: attr})
}, [defaultAttributes])
}, [geo])
/**
* Update user attributes on session change, and clear them on logout
*/
useEffect(() => {
if (currentAccount) {
const attr = getUserAttributes(currentAccount)
gb.setAttributes({
...defaultAttributes,
...getUserAttributes(currentAccount),
})
console.debug(`has session, set attributes`, {attributes: attr})
} else {
gb.setAttributes(defaultAttributes)
console.debug(`no session, reset attributes`, {
attributes: defaultAttributes,
})
}
}, [defaultAttributes, currentAccount])
setAttributesForAccount(currentAccount)
}, [currentAccount])
return <GrowthBookProvider growthbook={gb}>{children}</GrowthBookProvider>
}
/**
* Get the default attributes that should always be set
* on the GrowthBook instance
*/
function getDefaultAttributes() {
return {
device_id: getStableIdOrThrow(),
session_id: getSessionId(),
country: unsafeGeolocation?.countryCode || 'unknown',
}
}
/**
* Set attributes on the global GrowthBook instance. If an account is provided,
* set user attributes as well. Otherwise, clear user attributes.
*/
function setAttributesForAccount(account?: SessionAccount) {
if (account) {
const attr: Attributes = {
...getDefaultAttributes(),
...(getUserAttributes(account) || {}),
}
gb.setAttributes(attr)
console.debug(`setAttributesForAccount: has account`, {attributes: attr})
} else {
const attr = getDefaultAttributes()
gb.setAttributes(attr)
console.debug(`setAttributesForAccount: no account`, {attributes: attr})
}
}
/**
* Converts a SessionAccount into user attributes for GrowthBook
*/
export function getUserAttributes(account: SessionAccount): UserAttributes
export function getUserAttributes(account: undefined): null
export function getUserAttributes(
@@ -100,8 +221,9 @@ export function getUserAttributes(
if (!account) return null
const languagePrefs = persisted.get('languagePrefs')
return {
id: account.did,
pds: account.pdsUrl,
user_id: account.did,
did: account.did,
isBskyPds: account.service.startsWith(BSKY_SERVICE),
platform: Platform.OS,
appVersion: env.RELEASE_VERSION,
bundleIdentifier: env.BUNDLE_IDENTIFIER,
@@ -0,0 +1,24 @@
import uuid from 'react-native-uuid'
import AsyncStorage from '@react-native-async-storage/async-storage'
import {device} from '#/storage'
const LEGACY_STABLE_ID = 'STATSIG_LOCAL_STORAGE_STABLE_ID'
export async function getAndMigrateStableId() {
const id = (await AsyncStorage.getItem(LEGACY_STABLE_ID)) || uuid.v4()
device.set(['stableId'], id)
return id
}
export function getStableId() {
return device.get(['stableId'])
}
export function getStableIdOrThrow() {
const id = device.get(['stableId'])
if (!id) {
throw new Error(`stableId is not set, call getAndMigrateStableId first`)
}
return id
}
@@ -0,0 +1,10 @@
import uuid from 'react-native-uuid'
export * from '#/logger/growthbook/identifiers/common'
// TODO probably want to clear this
const sessionId = uuid.v4()
/**
* Stable session ID, persisted for the duration of the user's session
*/
export const getSessionId = () => sessionId
@@ -0,0 +1,15 @@
import uuid from 'react-native-uuid'
export * from '#/logger/growthbook/identifiers/common'
/**
* Stable session ID, persisted for the duration of the user's session
*/
export const getSessionId = () => {
let id = sessionStorage.getItem('BSKY_SESSION_ID')
if (!id) {
id = uuid.v4()
sessionStorage.setItem('BSKY_SESSION_ID', id)
}
return id
}
-8
View File
@@ -1,6 +1,5 @@
import {nanoid} from 'nanoid/non-secure'
import {Provider as GrowthbookProvider} from '#/logger/growthbook/context'
import {logEvent} from '#/lib/statsig/statsig'
import {add} from '#/logger/logDump'
import {type MetricEvents} from '#/logger/metrics'
@@ -172,10 +171,3 @@ export class Logger {
* `logger.error(error[, metadata])`
*/
export const logger = Logger.create(Logger.Context.Default)
/**
* Logger context provider
*/
export function Provider({children}: {children: React.ReactNode}) {
return <GrowthbookProvider>{children}</GrowthbookProvider>
}
+9 -6
View File
@@ -22,9 +22,9 @@ import {
PUBLIC_BSKY_SERVICE,
TIMELINE_SAVED_FEED,
} from '#/lib/constants'
import {tryFetchGates} from '#/lib/statsig/statsig'
import {getAge} from '#/lib/strings/time'
import {logger} from '#/logger'
import {refresh as refreshGates} from '#/logger/growthbook/context'
import {snoozeBirthdateUpdateAllowedForDid} from '#/state/birthdate'
import {snoozeEmailConfirmationPrompt} from '#/state/shell/reminders'
import {
@@ -63,7 +63,10 @@ export async function createAgentAndResume(
if (storedAccount.pdsUrl) {
agent.sessionManager.pdsUrl = new URL(storedAccount.pdsUrl)
}
const gates = tryFetchGates(storedAccount.did, 'prefer-low-latency')
const gates = refreshGates({
account: storedAccount,
strategy: 'prefer-low-latency',
})
const moderation = configureModerationForAccount(agent, storedAccount)
const prevSession: AtpSessionData = sessionAccountToSession(storedAccount)
if (isSessionExpired(storedAccount)) {
@@ -123,7 +126,7 @@ export async function createAgentAndLogin(
})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const aa = prefetchAgeAssuranceData({agent})
@@ -171,7 +174,7 @@ export async function createAgentAndCreateAccount(
verificationCode,
})
const account = agentToSessionAccountOrThrow(agent)
const gates = tryFetchGates(account.did, 'prefer-fresh-gates')
const gates = refreshGates({account, strategy: 'prefer-fresh-gates'})
const moderation = configureModerationForAccount(agent, account)
const createdAt = new Date().toISOString()
@@ -322,7 +325,7 @@ export function agentToSessionAccount(
return undefined
}
return {
service: agent.service.toString(),
service: agent.serviceUrl.toString(),
did: agent.session.did,
handle: agent.session.handle,
email: agent.session.email,
@@ -332,7 +335,7 @@ export function agentToSessionAccount(
accessJwt: agent.session.accessJwt,
signupQueued: isSignupQueued(agent.session.accessJwt),
active: agent.session.active,
status: agent.session.status as SessionAccount['status'],
status: agent.session.status,
pdsUrl: agent.pdsUrl?.toString(),
isSelfHosted: !agent.serviceUrl.toString().startsWith(BSKY_SERVICE),
}
+6
View File
@@ -5,6 +5,12 @@ import {type Geolocation} from '#/geolocation/types'
* Device data that's specific to the device and does not vary based account
*/
export type Device = {
/**
* Formerly managed by StatSig, this is the migrated stable ID for the
* device, used with our logging and metrics tracking.
*/
stableId: string | undefined
fontScale: '-2' | '-1' | '0' | '1' | '2'
fontFamily: 'system' | 'theme'
lastNuxDialog: string | undefined