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 * as debug from '#/ageAssurance/debug'
23import {logger} from '#/ageAssurance/logger'
24import {
25 getBirthdateStringFromAge,
26 isLegacyBirthdateBug,
27} from '#/ageAssurance/util'
28import {IS_DEV} from '#/env'
29import {device} from '#/storage'
30
31/**
32 * Special query client for age assurance data so we can prefetch on app
33 * load without interfering with other queries.
34 */
35const qc = new QueryClient({
36 defaultOptions: {
37 queries: {
38 /**
39 * We clear this manually, so disable automatic garbage collection.
40 * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works
41 */
42 gcTime: Infinity,
43 },
44 },
45})
46const persister = createAsyncStoragePersister({
47 storage: createPersistedQueryStorage('age-assurance'),
48 key: 'age-assurance-query-client',
49})
50const [, cacheHydrationPromise] = persistQueryClient({
51 queryClient: qc,
52 persister,
53})
54
55function getDidFromAgentSession(agent: AtpAgent) {
56 const sessionManager = agent.sessionManager
57 if (!sessionManager || !sessionManager.did) return
58 return sessionManager.did
59}
60
61/*
62 * Optimistic data
63 */
64
65const createdAtCache = new Map<string, string>()
66export function setCreatedAtForDid({
67 did,
68 createdAt,
69}: {
70 did: string
71 createdAt: string
72}) {
73 createdAtCache.set(did, createdAt)
74}
75const birthdateCache = new Map<string, string>()
76export function setBirthdateForDid({
77 did,
78 birthdate,
79}: {
80 did: string
81 birthdate: string
82}) {
83 birthdateCache.set(did, birthdate)
84}
85
86/*
87 * Config
88 */
89
90export const configQueryKey = ['config']
91export async function getConfig() {
92 if (debug.enabled) return debug.resolve(debug.config)
93 return {
94 regions: [],
95 }
96}
97export function getConfigFromCache():
98 | AppBskyAgeassuranceGetConfig.OutputSchema
99 | undefined {
100 return qc.getQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
101 configQueryKey,
102 )
103}
104let configPrefetchPromise: Promise<void> | undefined
105export async function prefetchConfig() {
106 if (configPrefetchPromise) {
107 logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
108 return
109 }
110
111 configPrefetchPromise = new Promise(async resolve => {
112 await cacheHydrationPromise
113 const cached = getConfigFromCache()
114
115 if (cached) {
116 logger.debug(`prefetchAgeAssuranceConfig: using cache`)
117 resolve()
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 (e: any) {
127 logger.warn(`prefetchAgeAssuranceConfig: failed`, {
128 safeMessage: e.message,
129 })
130 } finally {
131 resolve()
132 }
133 }
134 })
135}
136export async function refetchConfig() {
137 logger.debug(`refetchConfig: fetching...`)
138 const res = await getConfig()
139 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
140 configQueryKey,
141 res,
142 )
143 return res
144}
145export function useConfigQuery() {
146 return useQuery(
147 {
148 /**
149 * Will re-fetch when stale, at most every hour (or 5s in dev for easier
150 * testing).
151 *
152 * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
153 */
154 staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
155 /**
156 * N.B. if prefetch failed above, we'll have no `initialData`, and this
157 * query will run on startup.
158 */
159 initialData: getConfigFromCache(),
160 initialDataUpdatedAt: () =>
161 qc.getQueryState(configQueryKey)?.dataUpdatedAt,
162 queryKey: configQueryKey,
163 async queryFn() {
164 logger.debug(`useConfigQuery: fetching config`)
165 return getConfig()
166 },
167 },
168 qc,
169 )
170}
171
172/*
173 * Server state
174 */
175
176export function createServerStateQueryKey({did}: {did: string}) {
177 return ['serverState', did]
178}
179export async function getServerState() {
180 if (debug.enabled && debug.serverState)
181 return debug.resolve(debug.serverState)
182 return {
183 state: {
184 lastInitiatedAt: '2025-07-14T14:22:43.912Z',
185 status: 'assured' as const,
186 access: 'full' as const,
187 },
188 metadata: {
189 accountCreatedAt: '2022-11-17T00:35:16.391Z',
190 },
191 }
192}
193export function getServerStateFromCache({
194 did,
195}: {
196 did: string
197}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
198 return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
199 createServerStateQueryKey({did}),
200 )
201}
202export async function prefetchServerState({agent}: {agent: AtpAgent}) {
203 const did = getDidFromAgentSession(agent)
204
205 if (!did) return
206
207 await cacheHydrationPromise
208 const qk = createServerStateQueryKey({did})
209 const cached = getServerStateFromCache({did})
210
211 if (cached) {
212 logger.debug(`prefetchServerState: using cache`)
213 return
214 }
215
216 try {
217 logger.debug(`prefetchServerState: resolving...`)
218 const res = await networkRetry(3, () => getServerState())
219 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
220 } catch (e: any) {
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 async (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 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([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 (e: any) {
402 logger.warn(`prefetchOtherRequiredData: failed`, {
403 safeMessage: e.message,
404 })
405 }
406}
407export function usePatchOtherRequiredData() {
408 const {currentAccount} = useSession()
409 return useCallback(
410 async (next: OtherRequiredData) => {
411 if (!currentAccount) return
412 const did = currentAccount.did
413 const prev = getOtherRequiredDataFromCache({did})
414 const merged: OtherRequiredData = {
415 ...(prev || {}),
416 ...next,
417 }
418 qc.setQueryData<OtherRequiredData>(
419 createOtherRequiredDataQueryKey({did}),
420 merged,
421 )
422 },
423 [currentAccount],
424 )
425}
426export function useOtherRequiredDataQuery() {
427 const agent = useAgent()
428 const did = getDidFromAgentSession(agent)
429 return useQuery(
430 {
431 enabled: !!did,
432 initialData: () => {
433 if (!did) return
434 return getOtherRequiredDataFromCache({did})
435 },
436 queryKey: createOtherRequiredDataQueryKey({did: did!}),
437 async queryFn() {
438 return getOtherRequiredData({agent})
439 },
440 },
441 qc,
442 )
443}
444
445/**
446 * Helper to prefetch all age assurance data.
447 */
448export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
449 return Promise.allSettled([
450 // config fetch initiated at the top of the App.platform.tsx files, awaited here
451 configPrefetchPromise,
452 prefetchServerState({agent}),
453 prefetchOtherRequiredData({agent}),
454 ])
455}
456
457export function clearAgeAssuranceDataForDid({did}: {did: string}) {
458 logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
459 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
460 qc.removeQueries({
461 queryKey: createOtherRequiredDataQueryKey({did}),
462 exact: true,
463 })
464}
465
466export function clearAgeAssuranceData() {
467 logger.debug(`clearAgeAssuranceData`)
468 qc.clear()
469}
470
471/*
472 * Context
473 */
474
475export type AgeAssuranceData = {
476 config: AppBskyAgeassuranceDefs.Config | undefined
477 state: AppBskyAgeassuranceDefs.State | undefined
478 data:
479 | {
480 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
481 declaredAge: number | undefined
482 birthdate: string | undefined
483 }
484 | undefined
485}
486export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
487 config: undefined,
488 state: undefined,
489 data: {
490 accountCreatedAt: undefined,
491 declaredAge: undefined,
492 birthdate: undefined,
493 },
494})
495export function useAgeAssuranceDataContext() {
496 return useContext(AgeAssuranceDataContext)
497}
498export function AgeAssuranceDataProvider({
499 children,
500}: {
501 children: React.ReactNode
502}) {
503 const {data: config} = useConfigQuery()
504 const serverState = useServerStateQuery()
505 const {state, metadata} = serverState.data || {}
506 const {data} = useOtherRequiredDataQuery()
507 const ctx = useMemo(
508 () => ({
509 config,
510 state,
511 data: {
512 accountCreatedAt: metadata?.accountCreatedAt,
513 declaredAge: data?.birthdate
514 ? getAge(new Date(data.birthdate))
515 : undefined,
516 birthdate: data?.birthdate,
517 },
518 }),
519 [config, state, data, metadata],
520 )
521 return (
522 <AgeAssuranceDataContext.Provider value={ctx}>
523 {children}
524 </AgeAssuranceDataContext.Provider>
525 )
526}