forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {createContext, useCallback, useContext, useEffect, useMemo} from 'react'
2import {
3 type AppBskyAgeassuranceDefs,
4 type AppBskyAgeassuranceGetConfig,
5 type AppBskyAgeassuranceGetState,
6 type AtpAgent,
7 getAgeAssuranceRegionConfig,
8} from '@atproto/api'
9import {createAsyncStoragePersister} from '@tanstack/query-async-storage-persister'
10import {focusManager, QueryClient, useQuery} from '@tanstack/react-query'
11import {persistQueryClient} from '@tanstack/react-query-persist-client'
12import debounce from 'lodash.debounce'
13
14import {networkRetry} from '#/lib/async/retry'
15import {createPersistedQueryStorage} from '#/lib/persisted-query-storage'
16import {getAge} from '#/lib/strings/time'
17import {
18 hasSnoozedBirthdateUpdateForDid,
19 snoozeBirthdateUpdateAllowedForDid,
20} from '#/state/birthdate'
21import {useAgent, useSession} from '#/state/session'
22import {pdsAgent} from '#/state/session/agent'
23import * as debug from '#/ageAssurance/debug'
24import {logger} from '#/ageAssurance/logger'
25import {
26 getBirthdateStringFromAge,
27 isLegacyBirthdateBug,
28} from '#/ageAssurance/util'
29import {IS_DEV} from '#/env'
30import {device} from '#/storage'
31
32/**
33 * Special query client for age assurance data so we can prefetch on app
34 * load without interfering with other queries.
35 */
36const qc = new QueryClient({
37 defaultOptions: {
38 queries: {
39 /**
40 * We clear this manually, so disable automatic garbage collection.
41 * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
42 */
43 gcTime: Infinity,
44 },
45 },
46})
47const persister = createAsyncStoragePersister({
48 storage: createPersistedQueryStorage('age-assurance'),
49 key: 'age-assurance-query-client',
50})
51const [, cacheHydrationPromise] = persistQueryClient({
52 queryClient: qc,
53 persister,
54})
55
56function getDidFromAgentSession(agent: AtpAgent) {
57 const sessionManager = agent.sessionManager
58 if (!sessionManager || !sessionManager.did) return
59 return sessionManager.did
60}
61
62/*
63 * Optimistic data
64 */
65
66const createdAtCache = new Map<string, string>()
67export function setCreatedAtForDid({
68 did,
69 createdAt,
70}: {
71 did: string
72 createdAt: string
73}) {
74 createdAtCache.set(did, createdAt)
75}
76const birthdateCache = new Map<string, string>()
77export function setBirthdateForDid({
78 did,
79 birthdate,
80}: {
81 did: string
82 birthdate: string
83}) {
84 birthdateCache.set(did, birthdate)
85}
86
87/*
88 * Config
89 */
90
91export const configQueryKey = ['config']
92export async function getConfig() {
93 if (debug.enabled) return debug.resolve(debug.config)
94 return {
95 regions: [],
96 }
97}
98export function getConfigFromCache():
99 | AppBskyAgeassuranceGetConfig.OutputSchema
100 | undefined {
101 return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
102 configQueryKey,
103 )
104}
105let configPrefetchPromise: Promise<void> | undefined
106export function prefetchConfig() {
107 if (configPrefetchPromise) {
108 logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
109 return
110 }
111
112 configPrefetchPromise = (async () => {
113 await cacheHydrationPromise
114 const cached = getConfigFromCache()
115
116 if (cached) {
117 logger.debug(`prefetchAgeAssuranceConfig: using cache`)
118 } else {
119 try {
120 logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
121 const res = await networkRetry(3, () => getConfig())
122 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
123 configQueryKey,
124 res,
125 )
126 } catch (err) {
127 const e = err as Error
128 logger.warn(`prefetchAgeAssuranceConfig: failed`, {
129 safeMessage: e.message,
130 })
131 }
132 }
133 })()
134}
135export async function refetchConfig() {
136 logger.debug(`refetchConfig: fetching...`)
137 const res = await getConfig()
138 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
139 configQueryKey,
140 res,
141 )
142 return res
143}
144export function useConfigQuery() {
145 return useQuery(
146 {
147 /**
148 * Will re-fetch when stale, at most every hour (or 5s in dev for easier
149 * testing).
150 *
151 * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
152 */
153 staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
154 /**
155 * N.B. if prefetch failed above, we'll have no `initialData`, and this
156 * query will run on startup.
157 */
158 initialData: getConfigFromCache(),
159 initialDataUpdatedAt: () =>
160 qc.getQueryState(configQueryKey)?.dataUpdatedAt,
161 queryKey: configQueryKey,
162 async queryFn() {
163 logger.debug(`useConfigQuery: fetching config`)
164 return getConfig()
165 },
166 },
167 qc,
168 )
169}
170
171/*
172 * Server state
173 */
174
175export function createServerStateQueryKey({did}: {did: string}) {
176 return ['serverState', did]
177}
178export async function getServerState() {
179 if (debug.enabled && debug.serverState)
180 return debug.resolve(debug.serverState)
181 return {
182 state: {
183 lastInitiatedAt: '2025-07-14T14:22:43.912Z',
184 status: 'assured' as const,
185 access: 'full' as const,
186 },
187 metadata: {
188 accountCreatedAt: '2022-11-17T00:35:16.391Z',
189 },
190 }
191}
192export function getServerStateFromCache({
193 did,
194}: {
195 did: string
196}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
197 return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
198 createServerStateQueryKey({did}),
199 )
200}
201export async function prefetchServerState({agent}: {agent: AtpAgent}) {
202 const did = getDidFromAgentSession(agent)
203
204 if (!did) return
205
206 await cacheHydrationPromise
207 const qk = createServerStateQueryKey({did})
208 const cached = getServerStateFromCache({did})
209
210 if (cached) {
211 logger.debug(`prefetchServerState: using cache`)
212 return
213 }
214
215 try {
216 logger.debug(`prefetchServerState: resolving...`)
217 const res = await networkRetry(3, () => getServerState())
218 if (res) {
219 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
220 }
221 } catch (err) {
222 const e = err as Error
223 logger.warn(`prefetchServerState: failed`, {
224 safeMessage: e.message,
225 })
226 }
227}
228export async function refetchServerState({agent}: {agent: AtpAgent}) {
229 const did = getDidFromAgentSession(agent)
230 if (!did) return
231 logger.debug(`refetchServerState: fetching...`)
232 const res = await networkRetry(3, () => getServerState())
233 if (res) {
234 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
235 createServerStateQueryKey({did}),
236 res,
237 )
238 }
239 return res
240}
241export function usePatchServerState() {
242 const {currentAccount} = useSession()
243 return useCallback(
244 (next: AppBskyAgeassuranceDefs.State) => {
245 if (!currentAccount) return
246 const did = currentAccount.did
247 const prev = getServerStateFromCache({did})
248 const merged: AppBskyAgeassuranceGetState.OutputSchema = {
249 metadata: {},
250 ...(prev || {}),
251 state: next,
252 }
253 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
254 createServerStateQueryKey({did}),
255 merged,
256 )
257 },
258 [currentAccount],
259 )
260}
261export function useServerStateQuery() {
262 const agent = useAgent()
263 const did = getDidFromAgentSession(agent)
264 const query = useQuery(
265 {
266 enabled: !!did,
267 initialData: () => {
268 if (!did) return
269 return getServerStateFromCache({did})
270 },
271 queryKey: createServerStateQueryKey({did: did!}),
272 async queryFn() {
273 return getServerState()
274 },
275 },
276 qc,
277 )
278 const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
279
280 const isAssured = query.data?.state?.status === 'assured'
281
282 /**
283 * `refetchOnWindowFocus` doesn't seem to want to work for this custom query
284 * client, so we manually subscribe to focus changes.
285 */
286 useEffect(() => {
287 return focusManager.subscribe(() => {
288 // logged out
289 if (!did) return
290
291 const isFocused = focusManager.isFocused()
292
293 if (!isFocused) return
294
295 const config = getConfigFromCache()
296 const geolocation = device.get(['mergedGeolocation'])
297 const isAArequired = Boolean(
298 config &&
299 geolocation &&
300 !!getAgeAssuranceRegionConfig(config, {
301 countryCode: geolocation?.countryCode ?? '',
302 regionCode: geolocation?.regionCode,
303 }),
304 )
305
306 // only refetch when needed
307 if (isAssured || !isAArequired) return
308
309 void refetch()
310 })
311 }, [did, refetch, isAssured])
312
313 return query
314}
315
316/*
317 * Other required data
318 */
319
320export type OtherRequiredData = {
321 birthdate: string | undefined
322}
323export function createOtherRequiredDataQueryKey({did}: {did: string}) {
324 return ['otherRequiredData', did]
325}
326export async function getOtherRequiredData({
327 agent,
328}: {
329 agent: AtpAgent
330}): Promise<OtherRequiredData> {
331 if (debug.enabled) return debug.resolve(debug.otherRequiredData)
332 const [prefs] = await Promise.all([pdsAgent(agent).getPreferences()])
333 const data: OtherRequiredData = {
334 birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
335 }
336
337 /**
338 * If we can't read a birthdate, it may be due to the user accessing the
339 * account via an app password. In that case, fall-back to declared age
340 * flags.
341 */
342 if (!data.birthdate) {
343 if (prefs.declaredAge?.isOverAge18) {
344 data.birthdate = getBirthdateStringFromAge(18)
345 } else if (prefs.declaredAge?.isOverAge16) {
346 data.birthdate = getBirthdateStringFromAge(16)
347 } else if (prefs.declaredAge?.isOverAge13) {
348 data.birthdate = getBirthdateStringFromAge(13)
349 }
350 }
351
352 const did = getDidFromAgentSession(agent)
353 if (data && did && birthdateCache.has(did)) {
354 /*
355 * If birthdate was just set, use the local cache value. On subsequent
356 * reloads, the server should have the correct value.
357 */
358 data.birthdate = birthdateCache.get(did)
359 }
360
361 /**
362 * If the user is under the minimum age, and the birthdate is not due to the
363 * legacy bug, AND we've not already snoozed their birthdate update, snooze
364 * further birthdate updates for this user.
365 *
366 * This is basically a migration step for this initial rollout.
367 */
368 if (
369 data.birthdate &&
370 !isLegacyBirthdateBug(data.birthdate) &&
371 !hasSnoozedBirthdateUpdateForDid(did!)
372 ) {
373 snoozeBirthdateUpdateAllowedForDid(did!)
374 }
375
376 return data
377}
378export function getOtherRequiredDataFromCache({
379 did,
380}: {
381 did: string
382}): OtherRequiredData | undefined {
383 return qc.getQueryData<OtherRequiredData>(
384 createOtherRequiredDataQueryKey({did}),
385 )
386}
387export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
388 const did = getDidFromAgentSession(agent)
389
390 if (!did) return
391
392 await cacheHydrationPromise
393 const qk = createOtherRequiredDataQueryKey({did})
394 const cached = getOtherRequiredDataFromCache({did})
395
396 if (cached) {
397 logger.debug(`prefetchOtherRequiredData: using cache`)
398 return
399 }
400
401 try {
402 logger.debug(`prefetchOtherRequiredData: resolving...`)
403 const res = await networkRetry(3, () => getOtherRequiredData({agent}))
404 qc.setQueryData<OtherRequiredData>(qk, res)
405 } catch (err) {
406 const e = err as Error
407 logger.warn(`prefetchOtherRequiredData: failed`, {
408 safeMessage: e.message,
409 })
410 }
411}
412export function usePatchOtherRequiredData() {
413 const {currentAccount} = useSession()
414 return useCallback(
415 (next: OtherRequiredData) => {
416 if (!currentAccount) return
417 const did = currentAccount.did
418 const prev = getOtherRequiredDataFromCache({did})
419 const merged: OtherRequiredData = {
420 ...(prev || {}),
421 ...next,
422 }
423 qc.setQueryData<OtherRequiredData>(
424 createOtherRequiredDataQueryKey({did}),
425 merged,
426 )
427 },
428 [currentAccount],
429 )
430}
431export function useOtherRequiredDataQuery() {
432 const agent = useAgent()
433 const did = getDidFromAgentSession(agent)
434 return useQuery(
435 {
436 enabled: !!did,
437 initialData: () => {
438 if (!did) return
439 return getOtherRequiredDataFromCache({did})
440 },
441 queryKey: createOtherRequiredDataQueryKey({did: did!}),
442 async queryFn() {
443 return getOtherRequiredData({agent})
444 },
445 },
446 qc,
447 )
448}
449
450/**
451 * Helper to prefetch all age assurance data.
452 */
453export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
454 return Promise.allSettled([
455 // config fetch initiated at the top of the App.platform.tsx files, awaited here
456 configPrefetchPromise,
457 prefetchServerState({agent}),
458 prefetchOtherRequiredData({agent}),
459 ])
460}
461
462export function clearAgeAssuranceDataForDid({did}: {did: string}) {
463 logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
464 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
465 qc.removeQueries({
466 queryKey: createOtherRequiredDataQueryKey({did}),
467 exact: true,
468 })
469}
470
471export function clearAgeAssuranceData() {
472 logger.debug(`clearAgeAssuranceData`)
473 qc.clear()
474}
475
476/*
477 * Context
478 */
479
480export type AgeAssuranceData = {
481 config: AppBskyAgeassuranceDefs.Config | undefined
482 state: AppBskyAgeassuranceDefs.State | undefined
483 data:
484 | {
485 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
486 declaredAge: number | undefined
487 birthdate: string | undefined
488 }
489 | undefined
490}
491export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
492 config: undefined,
493 state: undefined,
494 data: {
495 accountCreatedAt: undefined,
496 declaredAge: undefined,
497 birthdate: undefined,
498 },
499})
500export function useAgeAssuranceDataContext() {
501 return useContext(AgeAssuranceDataContext)
502}
503export function AgeAssuranceDataProvider({
504 children,
505}: {
506 children: React.ReactNode
507}) {
508 const {data: config} = useConfigQuery()
509 const serverState = useServerStateQuery()
510 const {state, metadata} = serverState.data || {}
511 const {data} = useOtherRequiredDataQuery()
512 const ctx = useMemo(
513 () => ({
514 config,
515 state,
516 data: {
517 accountCreatedAt: metadata?.accountCreatedAt,
518 declaredAge: data?.birthdate
519 ? getAge(new Date(data.birthdate))
520 : undefined,
521 birthdate: data?.birthdate,
522 },
523 }),
524 [config, state, data, metadata],
525 )
526 return (
527 <AgeAssuranceDataContext.Provider value={ctx}>
528 {children}
529 </AgeAssuranceDataContext.Provider>
530 )
531}