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 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
219 } catch (err) {
220 const e = err as Error
221 logger.warn(`prefetchServerState: failed`, {
222 safeMessage: e.message,
223 })
224 }
225}
226export async function refetchServerState({agent}: {agent: AtpAgent}) {
227 const did = getDidFromAgentSession(agent)
228 if (!did) return
229 logger.debug(`refetchServerState: fetching...`)
230 const res = await networkRetry(3, () => getServerState())
231 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
232 createServerStateQueryKey({did}),
233 res,
234 )
235 return res
236}
237export function usePatchServerState() {
238 const {currentAccount} = useSession()
239 return useCallback(
240 (next: AppBskyAgeassuranceDefs.State) => {
241 if (!currentAccount) return
242 const did = currentAccount.did
243 const prev = getServerStateFromCache({did})
244 const merged: AppBskyAgeassuranceGetState.OutputSchema = {
245 metadata: {},
246 ...(prev || {}),
247 state: next,
248 }
249 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
250 createServerStateQueryKey({did}),
251 merged,
252 )
253 },
254 [currentAccount],
255 )
256}
257export function useServerStateQuery() {
258 const agent = useAgent()
259 const did = getDidFromAgentSession(agent)
260 const query = useQuery(
261 {
262 enabled: !!did,
263 initialData: () => {
264 if (!did) return
265 return getServerStateFromCache({did})
266 },
267 queryKey: createServerStateQueryKey({did: did!}),
268 async queryFn() {
269 return getServerState()
270 },
271 },
272 qc,
273 )
274 const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
275
276 const isAssured = query.data?.state?.status === 'assured'
277
278 /**
279 * `refetchOnWindowFocus` doesn't seem to want to work for this custom query
280 * client, so we manually subscribe to focus changes.
281 */
282 useEffect(() => {
283 return focusManager.subscribe(() => {
284 // logged out
285 if (!did) return
286
287 const isFocused = focusManager.isFocused()
288
289 if (!isFocused) return
290
291 const config = getConfigFromCache()
292 const geolocation = device.get(['mergedGeolocation'])
293 const isAArequired = Boolean(
294 config &&
295 geolocation &&
296 !!getAgeAssuranceRegionConfig(config, {
297 countryCode: geolocation?.countryCode ?? '',
298 regionCode: geolocation?.regionCode,
299 }),
300 )
301
302 // only refetch when needed
303 if (isAssured || !isAArequired) return
304
305 void refetch()
306 })
307 }, [did, refetch, isAssured])
308
309 return query
310}
311
312/*
313 * Other required data
314 */
315
316export type OtherRequiredData = {
317 birthdate: string | undefined
318}
319export function createOtherRequiredDataQueryKey({did}: {did: string}) {
320 return ['otherRequiredData', did]
321}
322export async function getOtherRequiredData({
323 agent,
324}: {
325 agent: AtpAgent
326}): Promise<OtherRequiredData> {
327 if (debug.enabled) return debug.resolve(debug.otherRequiredData)
328 const [prefs] = await Promise.all([pdsAgent(agent).getPreferences()])
329 const data: OtherRequiredData = {
330 birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
331 }
332
333 /**
334 * If we can't read a birthdate, it may be due to the user accessing the
335 * account via an app password. In that case, fall-back to declared age
336 * flags.
337 */
338 if (!data.birthdate) {
339 if (prefs.declaredAge?.isOverAge18) {
340 data.birthdate = getBirthdateStringFromAge(18)
341 } else if (prefs.declaredAge?.isOverAge16) {
342 data.birthdate = getBirthdateStringFromAge(16)
343 } else if (prefs.declaredAge?.isOverAge13) {
344 data.birthdate = getBirthdateStringFromAge(13)
345 }
346 }
347
348 const did = getDidFromAgentSession(agent)
349 if (data && did && birthdateCache.has(did)) {
350 /*
351 * If birthdate was just set, use the local cache value. On subsequent
352 * reloads, the server should have the correct value.
353 */
354 data.birthdate = birthdateCache.get(did)
355 }
356
357 /**
358 * If the user is under the minimum age, and the birthdate is not due to the
359 * legacy bug, AND we've not already snoozed their birthdate update, snooze
360 * further birthdate updates for this user.
361 *
362 * This is basically a migration step for this initial rollout.
363 */
364 if (
365 data.birthdate &&
366 !isLegacyBirthdateBug(data.birthdate) &&
367 !hasSnoozedBirthdateUpdateForDid(did!)
368 ) {
369 snoozeBirthdateUpdateAllowedForDid(did!)
370 }
371
372 return data
373}
374export function getOtherRequiredDataFromCache({
375 did,
376}: {
377 did: string
378}): OtherRequiredData | undefined {
379 return qc.getQueryData<OtherRequiredData>(
380 createOtherRequiredDataQueryKey({did}),
381 )
382}
383export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
384 const did = getDidFromAgentSession(agent)
385
386 if (!did) return
387
388 await cacheHydrationPromise
389 const qk = createOtherRequiredDataQueryKey({did})
390 const cached = getOtherRequiredDataFromCache({did})
391
392 if (cached) {
393 logger.debug(`prefetchOtherRequiredData: using cache`)
394 return
395 }
396
397 try {
398 logger.debug(`prefetchOtherRequiredData: resolving...`)
399 const res = await networkRetry(3, () => getOtherRequiredData({agent}))
400 qc.setQueryData<OtherRequiredData>(qk, res)
401 } catch (err) {
402 const e = err as Error
403 logger.warn(`prefetchOtherRequiredData: failed`, {
404 safeMessage: e.message,
405 })
406 }
407}
408export function usePatchOtherRequiredData() {
409 const {currentAccount} = useSession()
410 return useCallback(
411 (next: OtherRequiredData) => {
412 if (!currentAccount) return
413 const did = currentAccount.did
414 const prev = getOtherRequiredDataFromCache({did})
415 const merged: OtherRequiredData = {
416 ...(prev || {}),
417 ...next,
418 }
419 qc.setQueryData<OtherRequiredData>(
420 createOtherRequiredDataQueryKey({did}),
421 merged,
422 )
423 },
424 [currentAccount],
425 )
426}
427export function useOtherRequiredDataQuery() {
428 const agent = useAgent()
429 const did = getDidFromAgentSession(agent)
430 return useQuery(
431 {
432 enabled: !!did,
433 initialData: () => {
434 if (!did) return
435 return getOtherRequiredDataFromCache({did})
436 },
437 queryKey: createOtherRequiredDataQueryKey({did: did!}),
438 async queryFn() {
439 return getOtherRequiredData({agent})
440 },
441 },
442 qc,
443 )
444}
445
446/**
447 * Helper to prefetch all age assurance data.
448 */
449export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
450 return Promise.allSettled([
451 // config fetch initiated at the top of the App.platform.tsx files, awaited here
452 configPrefetchPromise,
453 prefetchServerState({agent}),
454 prefetchOtherRequiredData({agent}),
455 ])
456}
457
458export function clearAgeAssuranceDataForDid({did}: {did: string}) {
459 logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
460 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
461 qc.removeQueries({
462 queryKey: createOtherRequiredDataQueryKey({did}),
463 exact: true,
464 })
465}
466
467export function clearAgeAssuranceData() {
468 logger.debug(`clearAgeAssuranceData`)
469 qc.clear()
470}
471
472/*
473 * Context
474 */
475
476export type AgeAssuranceData = {
477 config: AppBskyAgeassuranceDefs.Config | undefined
478 state: AppBskyAgeassuranceDefs.State | undefined
479 data:
480 | {
481 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
482 declaredAge: number | undefined
483 birthdate: string | undefined
484 }
485 | undefined
486}
487export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
488 config: undefined,
489 state: undefined,
490 data: {
491 accountCreatedAt: undefined,
492 declaredAge: undefined,
493 birthdate: undefined,
494 },
495})
496export function useAgeAssuranceDataContext() {
497 return useContext(AgeAssuranceDataContext)
498}
499export function AgeAssuranceDataProvider({
500 children,
501}: {
502 children: React.ReactNode
503}) {
504 const {data: config} = useConfigQuery()
505 const serverState = useServerStateQuery()
506 const {state, metadata} = serverState.data || {}
507 const {data} = useOtherRequiredDataQuery()
508 const ctx = useMemo(
509 () => ({
510 config,
511 state,
512 data: {
513 accountCreatedAt: metadata?.accountCreatedAt,
514 declaredAge: data?.birthdate
515 ? getAge(new Date(data.birthdate))
516 : undefined,
517 birthdate: data?.birthdate,
518 },
519 }),
520 [config, state, data, metadata],
521 )
522 return (
523 <AgeAssuranceDataContext.Provider value={ctx}>
524 {children}
525 </AgeAssuranceDataContext.Provider>
526 )
527}