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

Configure Feed

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

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