@@ -9,10 +9,6 @@ import {Platform} from 'react-native'
|
|||||||
import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
|
import {type Result, type WidenPrimitives} from '@growthbook/growthbook-react'
|
||||||
|
|
||||||
import {Logger} from '#/logger'
|
import {Logger} from '#/logger'
|
||||||
import {
|
|
||||||
getCachedIsBetaUser,
|
|
||||||
subscribeToCachedIsBetaUser,
|
|
||||||
} from '#/state/preferences/beta-user-cache'
|
|
||||||
import {
|
import {
|
||||||
Features,
|
Features,
|
||||||
features as feats,
|
features as feats,
|
||||||
@@ -37,7 +33,7 @@ import {type Metrics, metrics} from '#/analytics/metrics'
|
|||||||
import * as refParams from '#/analytics/misc/refParams'
|
import * as refParams from '#/analytics/misc/refParams'
|
||||||
import * as env from '#/env'
|
import * as env from '#/env'
|
||||||
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
import {useGeolocationServiceResponse} from '#/geolocation/service'
|
||||||
import {device} from '#/storage'
|
import {account, device} from '#/storage'
|
||||||
|
|
||||||
export * as utils from '#/analytics/utils'
|
export * as utils from '#/analytics/utils'
|
||||||
export const features = {init, refresh}
|
export const features = {init, refresh}
|
||||||
@@ -135,7 +131,7 @@ export const setupDeviceId = getAndMigrateDeviceId()
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
* Reads the per-account cached `isBetaUser` flag for `did`, kept in sync with
|
||||||
* PDS preference query results and the beta settings toggle.
|
* writes from `BetaUserStorageSync` and the beta settings toggle.
|
||||||
*
|
*
|
||||||
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
* This deliberately does not use `useStorage`, whose `useState` seeds once and
|
||||||
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
* only updates via the change listener. The consuming `AnalyticsContext` lives
|
||||||
@@ -150,13 +146,17 @@ function useAccountIsBetaUser(did: string | undefined): boolean | undefined {
|
|||||||
const subscribe = useCallback(
|
const subscribe = useCallback(
|
||||||
(onChange: () => void) => {
|
(onChange: () => void) => {
|
||||||
if (!did) return () => {}
|
if (!did) return () => {}
|
||||||
return subscribeToCachedIsBetaUser(did, onChange)
|
const sub = account.addOnValueChangedListener(
|
||||||
|
[did, 'isBetaUser'],
|
||||||
|
onChange,
|
||||||
|
)
|
||||||
|
return () => sub.remove()
|
||||||
},
|
},
|
||||||
[did],
|
[did],
|
||||||
)
|
)
|
||||||
const getSnapshot = useCallback(() => {
|
const getSnapshot = useCallback(() => {
|
||||||
if (!did) return undefined
|
if (!did) return undefined
|
||||||
return getCachedIsBetaUser(did)
|
return account.get([did, 'isBetaUser'])
|
||||||
}, [did])
|
}, [did])
|
||||||
return useSyncExternalStore(subscribe, getSnapshot)
|
return useSyncExternalStore(subscribe, getSnapshot)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import {type NativeStackScreenProps} from '@react-navigation/native-stack'
|
|||||||
|
|
||||||
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
import {type CommonNavigatorParams} from '#/lib/routes/types'
|
||||||
import {logger} from '#/logger'
|
import {logger} from '#/logger'
|
||||||
import {setCachedIsBetaUser} from '#/state/preferences/beta-user-cache'
|
|
||||||
import {
|
import {
|
||||||
usePreferencesQuery,
|
usePreferencesQuery,
|
||||||
useSetIsBetaUserMutation,
|
useSetIsBetaUserMutation,
|
||||||
@@ -26,6 +25,7 @@ import {Text} from '#/components/Typography'
|
|||||||
import {features, useAnalytics} from '#/analytics'
|
import {features, useAnalytics} from '#/analytics'
|
||||||
import {getTargetedFeatures} from '#/analytics/features'
|
import {getTargetedFeatures} from '#/analytics/features'
|
||||||
import {IS_WEB} from '#/env'
|
import {IS_WEB} from '#/env'
|
||||||
|
import {account} from '#/storage'
|
||||||
|
|
||||||
type Props = NativeStackScreenProps<
|
type Props = NativeStackScreenProps<
|
||||||
CommonNavigatorParams,
|
CommonNavigatorParams,
|
||||||
@@ -74,7 +74,7 @@ export function BetaFeaturesSettingsScreen({}: Props) {
|
|||||||
* account-specific.
|
* account-specific.
|
||||||
*/
|
*/
|
||||||
if (currentAccount) {
|
if (currentAccount) {
|
||||||
setCachedIsBetaUser(currentAccount.did, next)
|
account.set([currentAccount.did, 'isBetaUser'], next)
|
||||||
}
|
}
|
||||||
ax.metric('betaFeatures:toggle', {
|
ax.metric('betaFeatures:toggle', {
|
||||||
enabled: next,
|
enabled: next,
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
import {account} from '#/storage'
|
|
||||||
|
|
||||||
const values = new Map<string, boolean | undefined>()
|
|
||||||
const listeners = new Map<string, Set<() => void>>()
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the last known beta-user preference for an account.
|
|
||||||
*
|
|
||||||
* The first read for a DID in this process is hydrated synchronously from
|
|
||||||
* persistent storage so cold starts retain the last value fetched from the
|
|
||||||
* PDS. Subsequent reads, including request-header reads, stay in memory.
|
|
||||||
*/
|
|
||||||
export function getCachedIsBetaUser(did: string): boolean | undefined {
|
|
||||||
if (!values.has(did)) {
|
|
||||||
try {
|
|
||||||
values.set(did, account.get([did, 'isBetaUser']))
|
|
||||||
} catch {
|
|
||||||
values.set(did, undefined)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return values.get(did)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the runtime cache and its persistent cold-start snapshot.
|
|
||||||
*
|
|
||||||
* Call this only with a value confirmed by the PDS, which remains the source
|
|
||||||
* of truth for the preference.
|
|
||||||
*/
|
|
||||||
export function setCachedIsBetaUser(did: string, value: boolean): void {
|
|
||||||
if (getCachedIsBetaUser(did) === value) return
|
|
||||||
account.set([did, 'isBetaUser'], value)
|
|
||||||
values.set(did, value)
|
|
||||||
listeners.get(did)?.forEach(listener => listener())
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Subscribe to runtime cache changes for one account.
|
|
||||||
*/
|
|
||||||
export function subscribeToCachedIsBetaUser(
|
|
||||||
did: string,
|
|
||||||
listener: () => void,
|
|
||||||
): () => void {
|
|
||||||
let didListeners = listeners.get(did)
|
|
||||||
if (!didListeners) {
|
|
||||||
didListeners = new Set()
|
|
||||||
listeners.set(did, didListeners)
|
|
||||||
}
|
|
||||||
didListeners.add(listener)
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
didListeners.delete(listener)
|
|
||||||
if (didListeners.size === 0) listeners.delete(did)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Drops the in-memory value so the next read rehydrates from persistence.
|
|
||||||
* This is useful when persistence is changed outside this cache.
|
|
||||||
*/
|
|
||||||
export function invalidateCachedIsBetaUser(did: string): void {
|
|
||||||
if (!values.delete(did)) return
|
|
||||||
listeners.get(did)?.forEach(listener => listener())
|
|
||||||
}
|
|
||||||
@@ -2,12 +2,12 @@ import {useEffect} from 'react'
|
|||||||
|
|
||||||
import {usePreferencesQuery} from '#/state/queries/preferences'
|
import {usePreferencesQuery} from '#/state/queries/preferences'
|
||||||
import {useSession} from '#/state/session'
|
import {useSession} from '#/state/session'
|
||||||
import {getCachedIsBetaUser, setCachedIsBetaUser} from './beta-user-cache'
|
import {account} from '#/storage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Caches `bskyAppState.isBetaUser` from preferences in memory and synchronous
|
* Caches `bskyAppState.isBetaUser` from preferences into synchronous device
|
||||||
* device storage so analytics can read it at init (before beta-gated features
|
* storage so analytics can read it at init (before beta-gated features are
|
||||||
* are evaluated). Scoped per account, since `isBetaUser` is account-specific:
|
* evaluated). Scoped per account, since `isBetaUser` is account-specific:
|
||||||
* a global cache would let one account's value leak into another after a
|
* 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
|
* switch, until the new account's preferences loaded. Must be mounted below
|
||||||
* `QueryProvider`, since the analytics providers that consume the cached value
|
* `QueryProvider`, since the analytics providers that consume the cached value
|
||||||
@@ -27,10 +27,10 @@ export function BetaUserStorageSync() {
|
|||||||
if (isBetaUser === undefined) return
|
if (isBetaUser === undefined) return
|
||||||
/*
|
/*
|
||||||
* Guard against a redundant write on every warm boot. Writing triggers the
|
* Guard against a redundant write on every warm boot. Writing triggers the
|
||||||
* cache change listener, which re-renders the analytics subtree.
|
* storage change listener, which re-renders the analytics subtree.
|
||||||
*/
|
*/
|
||||||
if (getCachedIsBetaUser(did) === isBetaUser) return
|
if (account.get([did, 'isBetaUser']) === isBetaUser) return
|
||||||
setCachedIsBetaUser(did, isBetaUser)
|
account.set([did, 'isBetaUser'], isBetaUser)
|
||||||
}, [did, isBetaUser])
|
}, [did, isBetaUser])
|
||||||
|
|
||||||
return null
|
return null
|
||||||
|
|||||||
@@ -14,12 +14,7 @@ jest.mock('jwt-decode', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants'
|
import {BLUESKY_PROXY_HEADER, CHAT_PROXY_SERVICE} from '#/lib/constants'
|
||||||
import {
|
|
||||||
invalidateCachedIsBetaUser,
|
|
||||||
setCachedIsBetaUser,
|
|
||||||
} from '#/state/preferences/beta-user-cache'
|
|
||||||
import {app, chat, com} from '#/lexicons'
|
import {app, chat, com} from '#/lexicons'
|
||||||
import {account} from '#/storage'
|
|
||||||
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
|
import {configureGlobalAppLabelers} from '../additional-moderation-authorities'
|
||||||
import {
|
import {
|
||||||
buildAppviewClient,
|
buildAppviewClient,
|
||||||
@@ -89,8 +84,6 @@ describe('buildAppviewClient', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
fetchMock = makeProfileFetch()
|
fetchMock = makeProfileFetch()
|
||||||
configureGlobalAppLabelers([])
|
configureGlobalAppLabelers([])
|
||||||
account.remove([DID, 'isBetaUser'])
|
|
||||||
invalidateCachedIsBetaUser(DID)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('passes through the session did', () => {
|
it('passes through the session did', () => {
|
||||||
@@ -118,46 +111,6 @@ describe('buildAppviewClient', () => {
|
|||||||
).toBe(BLUESKY_PROXY_HEADER.get())
|
).toBe(BLUESKY_PROXY_HEADER.get())
|
||||||
})
|
})
|
||||||
|
|
||||||
it.each([true, false])(
|
|
||||||
'emits the current beta user header when the cached value is %s',
|
|
||||||
async isBetaUser => {
|
|
||||||
const client = buildAppviewClient(makeSession(fetchMock))
|
|
||||||
setCachedIsBetaUser(DID, isBetaUser)
|
|
||||||
|
|
||||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
|
||||||
|
|
||||||
expect(
|
|
||||||
headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
|
|
||||||
'x-bsky-is-beta-user',
|
|
||||||
),
|
|
||||||
).toBe(String(isBetaUser))
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
it('omits the beta user header when the preference is not cached', async () => {
|
|
||||||
const client = buildAppviewClient(makeSession(fetchMock))
|
|
||||||
|
|
||||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
|
||||||
|
|
||||||
expect(
|
|
||||||
headersFor(fetchMock, 'app.bsky.actor.getProfile').get(
|
|
||||||
'x-bsky-is-beta-user',
|
|
||||||
),
|
|
||||||
).toBeNull()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reads the persisted beta preference only once', async () => {
|
|
||||||
account.set([DID, 'isBetaUser'], true)
|
|
||||||
const getSpy = jest.spyOn(account, 'get')
|
|
||||||
const client = buildAppviewClient(makeSession(fetchMock))
|
|
||||||
|
|
||||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
|
||||||
await client.call(app.bsky.actor.getProfile, {actor: HANDLE})
|
|
||||||
|
|
||||||
expect(getSpy).toHaveBeenCalledTimes(1)
|
|
||||||
getSpy.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits an account subscription exactly once', async () => {
|
it('emits an account subscription exactly once', async () => {
|
||||||
const client = buildAppviewClient(makeSession(fetchMock))
|
const client = buildAppviewClient(makeSession(fetchMock))
|
||||||
client.setLabelers(['did:plc:labeler'])
|
client.setLabelers(['did:plc:labeler'])
|
||||||
|
|||||||
@@ -7,33 +7,8 @@ import {
|
|||||||
PUBLIC_BSKY_SERVICE,
|
PUBLIC_BSKY_SERVICE,
|
||||||
} from '#/lib/constants'
|
} from '#/lib/constants'
|
||||||
import {createLexClient} from '#/lib/lexClient'
|
import {createLexClient} from '#/lib/lexClient'
|
||||||
import {getCachedIsBetaUser} from '#/state/preferences/beta-user-cache'
|
|
||||||
import {networkAwareFetch} from './network'
|
import {networkAwareFetch} from './network'
|
||||||
|
|
||||||
const IS_BETA_USER_HEADER = 'X-Bsky-Is-Beta-User'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add account-scoped headers to appview requests.
|
|
||||||
*
|
|
||||||
* Values are read from memory per request so preference changes are reflected
|
|
||||||
* immediately without rebuilding the session bundle.
|
|
||||||
*/
|
|
||||||
function withAppviewRequestHeaders(agent: Agent): Agent {
|
|
||||||
return {
|
|
||||||
get did() {
|
|
||||||
return agent.did
|
|
||||||
},
|
|
||||||
fetchHandler(path, init) {
|
|
||||||
const headers = new Headers(init?.headers)
|
|
||||||
const isBetaUser = agent.did ? getCachedIsBetaUser(agent.did) : undefined
|
|
||||||
if (isBetaUser !== undefined) {
|
|
||||||
headers.set(IS_BETA_USER_HEADER, String(isBetaUser))
|
|
||||||
}
|
|
||||||
return agent.fetchHandler(path, {...init, headers})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the signed-in appview {@link Client}.
|
* Build the signed-in appview {@link Client}.
|
||||||
*
|
*
|
||||||
@@ -53,9 +28,7 @@ function withAppviewRequestHeaders(agent: Agent): Agent {
|
|||||||
* fetch, which is `networkAwareFetch` wrapped in the disposal kill switch.
|
* fetch, which is `networkAwareFetch` wrapped in the disposal kill switch.
|
||||||
*/
|
*/
|
||||||
export function buildAppviewClient(agent: Agent): Client {
|
export function buildAppviewClient(agent: Agent): Client {
|
||||||
return createLexClient(withAppviewRequestHeaders(agent), {
|
return createLexClient(agent, {service: BLUESKY_PROXY_HEADER.get()})
|
||||||
service: BLUESKY_PROXY_HEADER.get(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -98,9 +98,10 @@ export type Account = {
|
|||||||
recentGifs?: Gif[]
|
recentGifs?: Gif[]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent cold-start snapshot of `bskyAppState.isBetaUser`. Hydrates the
|
* Cached from preferences (`bskyAppState.isBetaUser`) so the GrowthBook
|
||||||
* runtime cache so the GrowthBook attribute and request header are available
|
* `isBetaUser` attribute can be set synchronously at analytics init, before
|
||||||
* synchronously before preferences load. Written back when preferences load.
|
* beta-gated features (e.g. SearchV2Enable) are first evaluated. Written back
|
||||||
|
* when preferences load.
|
||||||
*
|
*
|
||||||
* Scoped per account, since `isBetaUser` is account-specific preference data.
|
* 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
|
* Reading it globally would let a beta account's value leak into a non-beta
|
||||||
|
|||||||
Reference in New Issue
Block a user