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

Configure Feed

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

at post-text-option 524 lines 14 kB view raw
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}