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