Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 527 lines 14 kB view raw
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}