Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

at feat/composer-acc-switch 558 lines 15 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 type ChatBskyActorDeclaration, 8 getAgeAssuranceRegionConfig, 9} from '@atproto/api' 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 {createPersistedQueryStorage} from '#/lib/persisted-query-storage' 17import {getAge} from '#/lib/strings/time' 18import { 19 hasSnoozedBirthdateUpdateForDid, 20 snoozeBirthdateUpdateAllowedForDid, 21} from '#/state/birthdate' 22import {fetchActorDeclarationRecord} from '#/state/queries/messages/actor-declaration' 23import {useAgent, useSession} from '#/state/session' 24import {pdsAgent} from '#/state/session/agent' 25import * as debug from '#/ageAssurance/debug' 26import {logger} from '#/ageAssurance/logger' 27import { 28 getBirthdateStringFromAge, 29 isLegacyBirthdateBug, 30} from '#/ageAssurance/util' 31import {IS_DEV} from '#/env' 32import {device} from '#/storage' 33 34/** 35 * Special query client for age assurance data so we can prefetch on app 36 * load without interfering with other queries. 37 */ 38const qc = new QueryClient({ 39 defaultOptions: { 40 queries: { 41 /** 42 * We clear this manually, so disable automatic garbage collection. 43 * @see https://tanstack.com/query/latest/docs/framework/react/plugins/persistQueryClient#how-it-works 44 */ 45 gcTime: Infinity, 46 }, 47 }, 48}) 49const persister = createAsyncStoragePersister({ 50 storage: createPersistedQueryStorage('age-assurance'), 51 key: 'age-assurance-query-client', 52}) 53const [, cacheHydrationPromise] = persistQueryClient({ 54 queryClient: qc, 55 persister, 56}) 57 58export function getDidFromAgentSession(agent: AtpAgent) { 59 const sessionManager = agent.sessionManager 60 if (!sessionManager || !sessionManager.did) return 61 return sessionManager.did 62} 63 64/* 65 * Optimistic data 66 */ 67 68const createdAtCache = new Map<string, string>() 69export function setCreatedAtForDid({ 70 did, 71 createdAt, 72}: { 73 did: string 74 createdAt: string 75}) { 76 createdAtCache.set(did, createdAt) 77} 78const birthdateCache = new Map<string, string>() 79export function setBirthdateForDid({ 80 did, 81 birthdate, 82}: { 83 did: string 84 birthdate: string 85}) { 86 birthdateCache.set(did, birthdate) 87} 88 89/* 90 * Config 91 */ 92 93export const configQueryKey = ['config'] 94export async function getConfig() { 95 if (debug.enabled) return debug.resolve(debug.config) 96 return { 97 regions: [], 98 } 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 function prefetchConfig() { 109 if (configPrefetchPromise) { 110 logger.debug(`prefetchAgeAssuranceConfig: already in progress`) 111 return 112 } 113 114 configPrefetchPromise = (async () => { 115 await cacheHydrationPromise 116 const cached = getConfigFromCache() 117 118 if (cached) { 119 logger.debug(`prefetchAgeAssuranceConfig: using cache`) 120 } else { 121 try { 122 logger.debug(`prefetchAgeAssuranceConfig: resolving...`) 123 const res = await networkRetry(3, () => getConfig()) 124 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 125 configQueryKey, 126 res, 127 ) 128 } catch (err) { 129 const e = err as Error 130 logger.warn(`prefetchAgeAssuranceConfig: failed`, { 131 safeMessage: e.message, 132 }) 133 } 134 } 135 })() 136} 137export async function refetchConfig() { 138 logger.debug(`refetchConfig: fetching...`) 139 const res = await getConfig() 140 qc.setQueryData<AppBskyAgeassuranceGetConfig.OutputSchema>( 141 configQueryKey, 142 res, 143 ) 144 return res 145} 146export function useConfigQuery() { 147 return useQuery( 148 { 149 /** 150 * Will re-fetch when stale, at most every hour (or 5s in dev for easier 151 * testing). 152 * 153 * @see https://tanstack.com/query/latest/docs/framework/react/guides/initial-query-data#initial-data-from-the-cache-with-initialdataupdatedat 154 */ 155 staleTime: IS_DEV ? 5e3 : 1000 * 60 * 60, 156 /** 157 * N.B. if prefetch failed above, we'll have no `initialData`, and this 158 * query will run on startup. 159 */ 160 initialData: getConfigFromCache(), 161 initialDataUpdatedAt: () => 162 qc.getQueryState(configQueryKey)?.dataUpdatedAt, 163 queryKey: configQueryKey, 164 async queryFn() { 165 logger.debug(`useConfigQuery: fetching config`) 166 return getConfig() 167 }, 168 }, 169 qc, 170 ) 171} 172 173/* 174 * Server state 175 */ 176 177export function createServerStateQueryKey({did}: {did: string}) { 178 return ['serverState', did] 179} 180export async function getServerState() { 181 if (debug.enabled && debug.serverState) 182 return debug.resolve(debug.serverState) 183 return { 184 state: { 185 lastInitiatedAt: '2025-07-14T14:22:43.912Z', 186 status: 'assured' as const, 187 access: 'full' as const, 188 }, 189 metadata: { 190 accountCreatedAt: '2022-11-17T00:35:16.391Z', 191 }, 192 } 193} 194export function getServerStateFromCache({ 195 did, 196}: { 197 did: string 198}): AppBskyAgeassuranceGetState.OutputSchema | undefined { 199 return qc.getQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 200 createServerStateQueryKey({did}), 201 ) 202} 203export async function prefetchServerState({agent}: {agent: AtpAgent}) { 204 const did = getDidFromAgentSession(agent) 205 206 if (!did) return 207 208 await cacheHydrationPromise 209 const qk = createServerStateQueryKey({did}) 210 const cached = getServerStateFromCache({did}) 211 212 if (cached) { 213 logger.debug(`prefetchServerState: using cache`) 214 return 215 } 216 217 try { 218 logger.debug(`prefetchServerState: resolving...`) 219 const res = await networkRetry(3, () => getServerState()) 220 if (res) { 221 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>(qk, res) 222 } 223 } catch (err) { 224 const e = err as Error 225 logger.warn(`prefetchServerState: failed`, { 226 safeMessage: e.message, 227 }) 228 } 229} 230export async function refetchServerState({agent}: {agent: AtpAgent}) { 231 const did = getDidFromAgentSession(agent) 232 if (!did) return 233 logger.debug(`refetchServerState: fetching...`) 234 const res = await networkRetry(3, () => getServerState()) 235 if (res) { 236 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 237 createServerStateQueryKey({did}), 238 res, 239 ) 240 } 241 return res 242} 243export function usePatchServerState() { 244 const {currentAccount} = useSession() 245 return useCallback( 246 (next: AppBskyAgeassuranceDefs.State) => { 247 if (!currentAccount) return 248 const did = currentAccount.did 249 const prev = getServerStateFromCache({did}) 250 const merged: AppBskyAgeassuranceGetState.OutputSchema = { 251 metadata: {}, 252 ...(prev || {}), 253 state: next, 254 } 255 qc.setQueryData<AppBskyAgeassuranceGetState.OutputSchema>( 256 createServerStateQueryKey({did}), 257 merged, 258 ) 259 }, 260 [currentAccount], 261 ) 262} 263export function useServerStateQuery() { 264 const agent = useAgent() 265 const did = getDidFromAgentSession(agent) 266 const query = useQuery( 267 { 268 enabled: !!did, 269 initialData: () => { 270 if (!did) return 271 return getServerStateFromCache({did}) 272 }, 273 queryKey: createServerStateQueryKey({did: did!}), 274 async queryFn() { 275 return getServerState() 276 }, 277 }, 278 qc, 279 ) 280 const refetch = useMemo(() => debounce(query.refetch, 100), [query.refetch]) 281 282 const isAssured = query.data?.state?.status === 'assured' 283 284 /** 285 * `refetchOnWindowFocus` doesn't seem to want to work for this custom query 286 * client, so we manually subscribe to focus changes. 287 */ 288 useEffect(() => { 289 return focusManager.subscribe(() => { 290 // logged out 291 if (!did) return 292 293 const isFocused = focusManager.isFocused() 294 295 if (!isFocused) return 296 297 const config = getConfigFromCache() 298 const geolocation = device.get(['mergedGeolocation']) 299 const isAArequired = Boolean( 300 config && 301 geolocation && 302 !!getAgeAssuranceRegionConfig(config, { 303 countryCode: geolocation?.countryCode ?? '', 304 regionCode: geolocation?.regionCode, 305 }), 306 ) 307 308 // only refetch when needed 309 if (isAssured || !isAArequired) return 310 311 void refetch() 312 }) 313 }, [did, refetch, isAssured]) 314 315 return query 316} 317 318/* 319 * Other required data 320 */ 321 322export type OtherRequiredData = { 323 birthdate: string | undefined 324 actorDeclaration?: ChatBskyActorDeclaration.Main 325} 326export function createOtherRequiredDataQueryKey({did}: {did: string}) { 327 return ['otherRequiredData', did] 328} 329async function getOtherRequiredData({ 330 agent, 331}: { 332 agent: AtpAgent 333}): Promise<OtherRequiredData> { 334 if (debug.enabled) return debug.resolve(debug.otherRequiredData) 335 const did = getDidFromAgentSession(pdsAgent(agent)) 336 const [prefs, actorDeclaration] = await Promise.all([ 337 pdsAgent(agent).getPreferences(), 338 fetchActorDeclarationRecord({did, agent: pdsAgent(agent)}), 339 ]) 340 const data: OtherRequiredData = { 341 birthdate: prefs.birthDate ? prefs.birthDate.toISOString() : undefined, 342 actorDeclaration, 343 } 344 345 /** 346 * If we can't read a birthdate, it may be due to the user accessing the 347 * account via an app password. In that case, fall-back to declared age 348 * flags. 349 */ 350 if (!data.birthdate) { 351 if (prefs.declaredAge?.isOverAge18) { 352 data.birthdate = getBirthdateStringFromAge(18) 353 } else if (prefs.declaredAge?.isOverAge16) { 354 data.birthdate = getBirthdateStringFromAge(16) 355 } else if (prefs.declaredAge?.isOverAge13) { 356 data.birthdate = getBirthdateStringFromAge(13) 357 } 358 } 359 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 function setOtherRequiredDataActorDeclarationCache({ 395 did, 396 actorDeclaration, 397}: { 398 did: string 399 actorDeclaration: ChatBskyActorDeclaration.Main 400}) { 401 const prev = getOtherRequiredDataFromCache({did}) 402 const next: OtherRequiredData = { 403 birthdate: prev?.birthdate, 404 actorDeclaration: { 405 ...(prev?.actorDeclaration || {}), 406 ...actorDeclaration, 407 }, 408 } 409 qc.setQueryData<OtherRequiredData>( 410 createOtherRequiredDataQueryKey({did}), 411 next, 412 ) 413} 414export async function prefetchOtherRequiredData({agent}: {agent: AtpAgent}) { 415 const did = getDidFromAgentSession(agent) 416 417 if (!did) return 418 419 await cacheHydrationPromise 420 const qk = createOtherRequiredDataQueryKey({did}) 421 const cached = getOtherRequiredDataFromCache({did}) 422 423 if (cached) { 424 logger.debug(`prefetchOtherRequiredData: using cache`) 425 return 426 } 427 428 try { 429 logger.debug(`prefetchOtherRequiredData: resolving...`) 430 const res = await networkRetry(3, () => getOtherRequiredData({agent})) 431 qc.setQueryData<OtherRequiredData>(qk, res) 432 } catch (err) { 433 const e = err as Error 434 logger.warn(`prefetchOtherRequiredData: failed`, { 435 safeMessage: e.message, 436 }) 437 } 438} 439export function usePatchOtherRequiredData() { 440 const {currentAccount} = useSession() 441 return useCallback( 442 (next: OtherRequiredData) => { 443 if (!currentAccount) return 444 const did = currentAccount.did 445 const prev = getOtherRequiredDataFromCache({did}) 446 const merged: OtherRequiredData = { 447 ...(prev || {}), 448 ...next, 449 } 450 qc.setQueryData<OtherRequiredData>( 451 createOtherRequiredDataQueryKey({did}), 452 merged, 453 ) 454 }, 455 [currentAccount], 456 ) 457} 458export function useOtherRequiredDataQuery() { 459 const agent = useAgent() 460 const did = getDidFromAgentSession(agent) 461 return useQuery( 462 { 463 enabled: !!did, 464 initialData: () => { 465 if (!did) return 466 return getOtherRequiredDataFromCache({did}) 467 }, 468 queryKey: createOtherRequiredDataQueryKey({did: did!}), 469 async queryFn() { 470 return getOtherRequiredData({agent}) 471 }, 472 }, 473 qc, 474 ) 475} 476 477/** 478 * Helper to prefetch all age assurance data. 479 */ 480export function prefetchAgeAssuranceData({agent}: {agent: AtpAgent}) { 481 return Promise.allSettled([ 482 // config fetch initiated at the top of the App.platform.tsx files, awaited here 483 configPrefetchPromise, 484 prefetchServerState({agent}), 485 prefetchOtherRequiredData({agent}), 486 ]) 487} 488 489export function clearAgeAssuranceDataForDid({did}: {did: string}) { 490 logger.debug(`clearAgeAssuranceDataForDid: ${did}`) 491 qc.removeQueries({queryKey: createServerStateQueryKey({did}), exact: true}) 492 qc.removeQueries({ 493 queryKey: createOtherRequiredDataQueryKey({did}), 494 exact: true, 495 }) 496} 497 498export function clearAgeAssuranceData() { 499 logger.debug(`clearAgeAssuranceData`) 500 qc.clear() 501} 502 503/* 504 * Context 505 */ 506 507export type AgeAssuranceData = { 508 config: AppBskyAgeassuranceDefs.Config | undefined 509 state: AppBskyAgeassuranceDefs.State | undefined 510 data: 511 | { 512 accountCreatedAt: AppBskyAgeassuranceDefs.StateMetadata['accountCreatedAt'] 513 declaredAge: number | undefined 514 birthdate: string | undefined 515 } 516 | undefined 517} 518export const AgeAssuranceDataContext = createContext<AgeAssuranceData>({ 519 config: undefined, 520 state: undefined, 521 data: { 522 accountCreatedAt: undefined, 523 declaredAge: undefined, 524 birthdate: undefined, 525 }, 526}) 527export function useAgeAssuranceDataContext() { 528 return useContext(AgeAssuranceDataContext) 529} 530export function AgeAssuranceDataProvider({ 531 children, 532}: { 533 children: React.ReactNode 534}) { 535 const {data: config} = useConfigQuery() 536 const serverState = useServerStateQuery() 537 const {state, metadata} = serverState.data || {} 538 const {data} = useOtherRequiredDataQuery() 539 const ctx = useMemo( 540 () => ({ 541 config, 542 state, 543 data: { 544 accountCreatedAt: metadata?.accountCreatedAt, 545 declaredAge: data?.birthdate 546 ? getAge(new Date(data.birthdate)) 547 : undefined, 548 birthdate: data?.birthdate, 549 }, 550 }), 551 [config, state, data, metadata], 552 ) 553 return ( 554 <AgeAssuranceDataContext.Provider value={ctx}> 555 {children} 556 </AgeAssuranceDataContext.Provider> 557 ) 558}