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