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 AtpAgent,
7 getAgeAssuranceRegionConfig,
8} from '@atproto/api'
9import AsyncStorage from '@react-native-async-storage/async-storage'
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 {PUBLIC_BSKY_SERVICE} from '#/lib/constants'
17import {getAge} from '#/lib/strings/time'
18import {
19 hasSnoozedBirthdateUpdateForDid,
20 snoozeBirthdateUpdateAllowedForDid,
21} from '#/state/birthdate'
22import {useAgent, useSession} from '#/state/session'
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: AsyncStorage,
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 const agent = new AtpAgent({
95 service: PUBLIC_BSKY_SERVICE,
96 })
97 const res = await agent.app.bsky.ageassurance.getConfig()
98 return res.data
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 async function prefetchConfig() {
109 if (configPrefetchPromise) {
110 logger.debug(`prefetchAgeAssuranceConfig: already in progress`)
111 return
112 }
113
114 configPrefetchPromise = new Promise(async resolve => {
115 await cacheHydrationPromise
116 const cached = getConfigFromCache()
117
118 if (cached) {
119 logger.debug(`prefetchAgeAssuranceConfig: using cache`)
120 resolve()
121 } else {
122 try {
123 logger.debug(`prefetchAgeAssuranceConfig: resolving...`)
124 const res = await networkRetry(3, () => getConfig())
125 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
126 configQueryKey,
127 res,
128 )
129 } catch (e: any) {
130 logger.warn(`prefetchAgeAssuranceConfig: failed`, {
131 safeMessage: e.message,
132 })
133 } finally {
134 resolve()
135 }
136 }
137 })
138}
139export async function refetchConfig() {
140 logger.debug(`refetchConfig: fetching...`)
141 const res = await getConfig()
142 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>(
143 configQueryKey,
144 res,
145 )
146 return res
147}
148export function useConfigQuery() {
149 return useQuery(
150 {
151 /**
152 * Will re-fetch when stale, at most every hour (or 5s in dev for easier
153 * testing).
154 *
155 * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat
156 */
157 staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60,
158 /**
159 * N.B. if prefetch failed above, we'll have no `initialData`, and this
160 * query will run on startup.
161 */
162 initialData: getConfigFromCache(),
163 initialDataUpdatedAt: () =>
164 qc.getQueryState(configQueryKey)?.dataUpdatedAt,
165 queryKey: configQueryKey,
166 async queryFn() {
167 logger.debug(`useConfigQuery: fetching config`)
168 return getConfig()
169 },
170 },
171 qc,
172 )
173}
174
175/*
176 * Server state
177 */
178
179export function createServerStateQueryKey({did}: {did: string}) {
180 return ['serverState', did]
181}
182export async function getServerState({agent}: {agent: AtpAgent}) {
183 if (debug.enabled && debug.serverState)
184 return debug.resolve(debug.serverState)
185 const geolocation = device.get(['mergedGeolocation'])
186 if (!geolocation || !geolocation.countryCode) {
187 logger.error(`getServerState: missing geolocation countryCode`)
188 return
189 }
190 const {data} = await agent.app.bsky.ageassurance.getState({
191 countryCode: geolocation.countryCode,
192 regionCode: geolocation.regionCode,
193 })
194 const did = getDidFromAgentSession(agent)
195 if (data && did && createdAtCache.has(did)) {
196 /*
197 * If account was just created, just use the local cache if available. On
198 * subsequent reloads, the server should have the correct value.
199 */
200 data.metadata.accountCreatedAt = createdAtCache.get(did)
201 }
202 return data ?? null
203}
204export function getServerStateFromCache({
205 did,
206}: {
207 did: string
208}): AppBskyAgeassuranceGetState.OutputSchema | undefined {
209 return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
210 createServerStateQueryKey({did}),
211 )
212}
213export async function prefetchServerState({agent}: {agent: AtpAgent}) {
214 const did = getDidFromAgentSession(agent)
215
216 if (!did) return
217
218 await cacheHydrationPromise
219 const qk = createServerStateQueryKey({did})
220 const cached = getServerStateFromCache({did})
221
222 if (cached) {
223 logger.debug(`prefetchServerState: using cache`)
224 return
225 }
226
227 try {
228 logger.debug(`prefetchServerState: resolving...`)
229 const res = await networkRetry(3, () => getServerState({agent}))
230 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res)
231 } catch (e: any) {
232 logger.warn(`prefetchServerState: failed`, {
233 safeMessage: e.message,
234 })
235 }
236}
237export async function refetchServerState({agent}: {agent: AtpAgent}) {
238 const did = getDidFromAgentSession(agent)
239 if (!did) return
240 logger.debug(`refetchServerState: fetching...`)
241 const res = await networkRetry(3, () => getServerState({agent}))
242 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
243 createServerStateQueryKey({did}),
244 res,
245 )
246 return res
247}
248export function usePatchServerState() {
249 const {currentAccount} = useSession()
250 return useCallback(
251 async (next: AppBskyAgeassuranceDefs.State) => {
252 if (!currentAccount) return
253 const did = currentAccount.did
254 const prev = getServerStateFromCache({did})
255 const merged: AppBskyAgeassuranceGetState.OutputSchema = {
256 metadata: {},
257 ...(prev || {}),
258 state: next,
259 }
260 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(
261 createServerStateQueryKey({did}),
262 merged,
263 )
264 },
265 [currentAccount],
266 )
267}
268export function useServerStateQuery() {
269 const agent = useAgent()
270 const did = getDidFromAgentSession(agent)
271 const query = useQuery(
272 {
273 enabled: !!did,
274 initialData: () => {
275 if (!did) return
276 return getServerStateFromCache({did})
277 },
278 queryKey: createServerStateQueryKey({did: did!}),
279 async queryFn() {
280 return getServerState({agent})
281 },
282 },
283 qc,
284 )
285 const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch])
286
287 const isAssured = query.data?.state?.status === 'assured'
288
289 /**
290 * `refetchOnWindowFocus` doesn't seem to want to work for this custom query
291 * client, so we manually subscribe to focus changes.
292 */
293 useEffect(() => {
294 return focusManager.subscribe(() => {
295 // logged out
296 if (!did) return
297
298 const isFocused = focusManager.isFocused()
299
300 if (!isFocused) return
301
302 const config = getConfigFromCache()
303 const geolocation = device.get(['mergedGeolocation'])
304 const isAArequired = Boolean(
305 config &&
306 geolocation &&
307 !!getAgeAssuranceRegionConfig(config, {
308 countryCode: geolocation?.countryCode ?? '',
309 regionCode: geolocation?.regionCode,
310 }),
311 )
312
313 // only refetch when needed
314 if (isAssured || !isAArequired) return
315
316 refetch()
317 })
318 }, [did, refetch, isAssured])
319
320 return query
321}
322
323/*
324 * Other required data
325 */
326
327export type OtherRequiredData = {
328 birthdate: string | undefined
329}
330export function createOtherRequiredDataQueryKey({did}: {did: string}) {
331 return ['otherRequiredData', did]
332}
333export async function getOtherRequiredData({
334 agent,
335}: {
336 agent: AtpAgent
337}): Promise<OtherRequiredData> {
338 if (debug.enabled) return debug.resolve(debug.otherRequiredData)
339 const [prefs] = await Promise.all([agent.getPreferences()])
340 const data: OtherRequiredData = {
341 birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined,
342 }
343
344 /**
345 * If we can't read a birthdate, it may be due to the user accessing the
346 * account via an app password. In that case, fall-back to declared age
347 * flags.
348 */
349 if (!data.birthdate) {
350 if (prefs.declaredAge?.isOverAge18) {
351 data.birthdate = getBirthdateStringFromAge(18)
352 } else if (prefs.declaredAge?.isOverAge16) {
353 data.birthdate = getBirthdateStringFromAge(16)
354 } else if (prefs.declaredAge?.isOverAge13) {
355 data.birthdate = getBirthdateStringFromAge(13)
356 }
357 }
358
359 const did = getDidFromAgentSession(agent)
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 async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) {
395 const did = getDidFromAgentSession(agent)
396
397 if (!did) return
398
399 await cacheHydrationPromise
400 const qk = createOtherRequiredDataQueryKey({did})
401 const cached = getOtherRequiredDataFromCache({did})
402
403 if (cached) {
404 logger.debug(`prefetchOtherRequiredData: using cache`)
405 return
406 }
407
408 try {
409 logger.debug(`prefetchOtherRequiredData: resolving...`)
410 const res = await networkRetry(3, () => getOtherRequiredData({agent}))
411 qc.setQueryData<OtherRequiredData>(qk, res)
412 } catch (e: any) {
413 logger.warn(`prefetchOtherRequiredData: failed`, {
414 safeMessage: e.message,
415 })
416 }
417}
418export function usePatchOtherRequiredData() {
419 const {currentAccount} = useSession()
420 return useCallback(
421 async (next: OtherRequiredData) => {
422 if (!currentAccount) return
423 const did = currentAccount.did
424 const prev = getOtherRequiredDataFromCache({did})
425 const merged: OtherRequiredData = {
426 ...(prev || {}),
427 ...next,
428 }
429 qc.setQueryData<OtherRequiredData>(
430 createOtherRequiredDataQueryKey({did}),
431 merged,
432 )
433 },
434 [currentAccount],
435 )
436}
437export function useOtherRequiredDataQuery() {
438 const agent = useAgent()
439 const did = getDidFromAgentSession(agent)
440 return useQuery(
441 {
442 enabled: !!did,
443 initialData: () => {
444 if (!did) return
445 return getOtherRequiredDataFromCache({did})
446 },
447 queryKey: createOtherRequiredDataQueryKey({did: did!}),
448 async queryFn() {
449 return getOtherRequiredData({agent})
450 },
451 },
452 qc,
453 )
454}
455
456/**
457 * Helper to prefetch all age assurance data.
458 */
459export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) {
460 return Promise.allSettled([
461 // config fetch initiated at the top of the App.platform.tsx files, awaited here
462 configPrefetchPromise,
463 prefetchServerState({agent}),
464 prefetchOtherRequiredData({agent}),
465 ])
466}
467
468export function clearAgeAssuranceDataForDid({did}: {did: string}) {
469 logger.debug(`clearAgeAssuranceDataForDid: ${did}`)
470 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true})
471 qc.removeQueries({
472 queryKey: createOtherRequiredDataQueryKey({did}),
473 exact: true,
474 })
475}
476
477export function clearAgeAssuranceData() {
478 logger.debug(`clearAgeAssuranceData`)
479 qc.clear()
480}
481
482/*
483 * Context
484 */
485
486export type AgeAssuranceData = {
487 config: AppBskyAgeassuranceDefs.Config | undefined
488 state: AppBskyAgeassuranceDefs.State | undefined
489 data:
490 | {
491 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt']
492 declaredAge: number | undefined
493 birthdate: string | undefined
494 }
495 | undefined
496}
497export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({
498 config: undefined,
499 state: undefined,
500 data: {
501 accountCreatedAt: undefined,
502 declaredAge: undefined,
503 birthdate: undefined,
504 },
505})
506export function useAgeAssuranceDataContext() {
507 return useContext(AgeAssuranceDataContext)
508}
509export function AgeAssuranceDataProvider({
510 children,
511}: {
512 children: React.ReactNode
513}) {
514 const {data: config} = useConfigQuery()
515 const serverState = useServerStateQuery()
516 const {state, metadata} = serverState.data || {}
517 const {data} = useOtherRequiredDataQuery()
518 const ctx = useMemo(
519 () => ({
520 config,
521 state,
522 data: {
523 accountCreatedAt: metadata?.accountCreatedAt,
524 declaredAge: data?.birthdate
525 ? getAge(new Date(data.birthdate))
526 : undefined,
527 birthdate: data?.birthdate,
528 },
529 }),
530 [config, state, data, metadata],
531 )
532 return (
533 <AgeAssuranceDataContext.Provider value={ctx}>
534 {children}
535 </AgeAssuranceDataContext.Provider>
536 )
537}