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