An ATproto social media client -- with an independent Appview.
7
fork

Configure Feed

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

at main 582 lines 17 kB view raw
1import {useCallback} from 'react' 2import { 3 type AppBskyActorDefs, 4 type AppBskyActorGetProfile, 5 type AppBskyActorGetProfiles, 6 type AppBskyActorProfile, 7 AtUri, 8 type BskyAgent, 9 type ComAtprotoRepoUploadBlob, 10 type Un$Typed, 11} from '@atproto/api' 12import { 13 keepPreviousData, 14 type QueryClient, 15 useMutation, 16 useQuery, 17 useQueryClient, 18} from '@tanstack/react-query' 19 20import {uploadBlob} from '#/lib/api' 21import {until} from '#/lib/async/until' 22import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue' 23import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig' 24import {updateProfileShadow} from '#/state/cache/profile-shadow' 25import {type Shadow} from '#/state/cache/types' 26import {type ImageMeta} from '#/state/gallery' 27import {STALE} from '#/state/queries' 28import {resetProfilePostsQueries} from '#/state/queries/post-feed' 29import { 30 unstableCacheProfileView, 31 useUnstableProfileViewCache, 32} from '#/state/queries/unstable-profile-cache' 33import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache' 34import {useAgent, useSession} from '#/state/session' 35import * as userActionHistory from '#/state/userActionHistory' 36import type * as bsky from '#/types/bsky' 37import { 38 ProgressGuideAction, 39 useProgressGuideControls, 40} from '../shell/progress-guide' 41import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations' 42import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts' 43import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts' 44 45export * from '#/state/queries/unstable-profile-cache' 46/** 47 * @deprecated use {@link unstableCacheProfileView} instead 48 */ 49export const precacheProfile = unstableCacheProfileView 50 51const RQKEY_ROOT = 'profile' 52export const RQKEY = (did: string) => [RQKEY_ROOT, did] 53 54export const profilesQueryKeyRoot = 'profiles' 55export const profilesQueryKey = (handles: string[]) => [ 56 profilesQueryKeyRoot, 57 handles, 58] 59 60export function useProfileQuery({ 61 did, 62 staleTime = STALE.SECONDS.FIFTEEN, 63}: { 64 did: string | undefined 65 staleTime?: number 66}) { 67 const agent = useAgent() 68 const {getUnstableProfile} = useUnstableProfileViewCache() 69 return useQuery<AppBskyActorDefs.ProfileViewDetailed>({ 70 // WARNING 71 // this staleTime is load-bearing 72 // if you remove it, the UI infinite-loops 73 // -prf 74 staleTime, 75 refetchOnWindowFocus: true, 76 queryKey: RQKEY(did ?? ''), 77 queryFn: async () => { 78 const res = await agent.getProfile({actor: did ?? ''}) 79 return res.data 80 }, 81 placeholderData: () => { 82 if (!did) return 83 return getUnstableProfile(did) as AppBskyActorDefs.ProfileViewDetailed 84 }, 85 enabled: !!did, 86 }) 87} 88 89export function useProfilesQuery({ 90 handles, 91 maintainData, 92}: { 93 handles: string[] 94 maintainData?: boolean 95}) { 96 const agent = useAgent() 97 return useQuery({ 98 staleTime: STALE.MINUTES.FIVE, 99 queryKey: profilesQueryKey(handles), 100 queryFn: async () => { 101 const res = await agent.getProfiles({actors: handles}) 102 return res.data 103 }, 104 placeholderData: maintainData ? keepPreviousData : undefined, 105 }) 106} 107 108export function usePrefetchProfileQuery() { 109 const agent = useAgent() 110 const queryClient = useQueryClient() 111 const prefetchProfileQuery = useCallback( 112 async (did: string) => { 113 await queryClient.prefetchQuery({ 114 staleTime: STALE.SECONDS.THIRTY, 115 queryKey: RQKEY(did), 116 queryFn: async () => { 117 const res = await agent.getProfile({actor: did || ''}) 118 return res.data 119 }, 120 }) 121 }, 122 [queryClient, agent], 123 ) 124 return prefetchProfileQuery 125} 126 127interface ProfileUpdateParams { 128 profile: AppBskyActorDefs.ProfileViewDetailed 129 updates: 130 | Un$Typed<AppBskyActorProfile.Record> 131 | (( 132 existing: Un$Typed<AppBskyActorProfile.Record>, 133 ) => Un$Typed<AppBskyActorProfile.Record>) 134 newUserAvatar?: ImageMeta | undefined | null 135 newUserBanner?: ImageMeta | undefined | null 136 checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean 137} 138export function useProfileUpdateMutation() { 139 const queryClient = useQueryClient() 140 const agent = useAgent() 141 const updateProfileVerificationCache = useUpdateProfileVerificationCache() 142 return useMutation<void, Error, ProfileUpdateParams>({ 143 mutationFn: async ({ 144 profile, 145 updates, 146 newUserAvatar, 147 newUserBanner, 148 checkCommitted, 149 }) => { 150 let newUserAvatarPromise: 151 | Promise<ComAtprotoRepoUploadBlob.Response> 152 | undefined 153 if (newUserAvatar) { 154 newUserAvatarPromise = uploadBlob( 155 agent, 156 newUserAvatar.path, 157 newUserAvatar.mime, 158 ) 159 } 160 let newUserBannerPromise: 161 | Promise<ComAtprotoRepoUploadBlob.Response> 162 | undefined 163 if (newUserBanner) { 164 newUserBannerPromise = uploadBlob( 165 agent, 166 newUserBanner.path, 167 newUserBanner.mime, 168 ) 169 } 170 await agent.upsertProfile(async existing => { 171 let next: Un$Typed<AppBskyActorProfile.Record> = existing || {} 172 if (typeof updates === 'function') { 173 next = updates(next) 174 } else { 175 next.displayName = updates.displayName 176 next.description = updates.description 177 if ('pinnedPost' in updates) { 178 next.pinnedPost = updates.pinnedPost 179 } 180 } 181 if (newUserAvatarPromise) { 182 const res = await newUserAvatarPromise 183 next.avatar = res.data.blob 184 } else if (newUserAvatar === null) { 185 next.avatar = undefined 186 } 187 if (newUserBannerPromise) { 188 const res = await newUserBannerPromise 189 next.banner = res.data.blob 190 } else if (newUserBanner === null) { 191 next.banner = undefined 192 } 193 return next 194 }) 195 await whenAppViewReady( 196 agent, 197 profile.did, 198 checkCommitted || 199 (res => { 200 if (typeof newUserAvatar !== 'undefined') { 201 if (newUserAvatar === null && res.data.avatar) { 202 // url hasnt cleared yet 203 return false 204 } else if (res.data.avatar === profile.avatar) { 205 // url hasnt changed yet 206 return false 207 } 208 } 209 if (typeof newUserBanner !== 'undefined') { 210 if (newUserBanner === null && res.data.banner) { 211 // url hasnt cleared yet 212 return false 213 } else if (res.data.banner === profile.banner) { 214 // url hasnt changed yet 215 return false 216 } 217 } 218 if (typeof updates === 'function') { 219 return true 220 } 221 return ( 222 res.data.displayName === updates.displayName && 223 res.data.description === updates.description 224 ) 225 }), 226 ) 227 }, 228 async onSuccess(_, variables) { 229 // invalidate cache 230 queryClient.invalidateQueries({ 231 queryKey: RQKEY(variables.profile.did), 232 }) 233 queryClient.invalidateQueries({ 234 queryKey: [profilesQueryKeyRoot, [variables.profile.did]], 235 }) 236 await updateProfileVerificationCache({profile: variables.profile}) 237 }, 238 }) 239} 240 241export function useProfileFollowMutationQueue( 242 profile: Shadow<bsky.profile.AnyProfileView>, 243 logContext: LogEvents['profile:follow']['logContext'] & 244 LogEvents['profile:follow']['logContext'], 245) { 246 const agent = useAgent() 247 const queryClient = useQueryClient() 248 const did = profile.did 249 const initialFollowingUri = profile.viewer?.following 250 const followMutation = useProfileFollowMutation(logContext, profile) 251 const unfollowMutation = useProfileUnfollowMutation(logContext) 252 253 const queueToggle = useToggleMutationQueue({ 254 initialState: initialFollowingUri, 255 runMutation: async (prevFollowingUri, shouldFollow) => { 256 if (shouldFollow) { 257 const {uri} = await followMutation.mutateAsync({ 258 did, 259 }) 260 userActionHistory.follow([did]) 261 return uri 262 } else { 263 if (prevFollowingUri) { 264 await unfollowMutation.mutateAsync({ 265 did, 266 followUri: prevFollowingUri, 267 }) 268 userActionHistory.unfollow([did]) 269 } 270 return undefined 271 } 272 }, 273 onSuccess(finalFollowingUri) { 274 // finalize 275 updateProfileShadow(queryClient, did, { 276 followingUri: finalFollowingUri, 277 }) 278 279 if (finalFollowingUri) { 280 agent.app.bsky.graph 281 .getSuggestedFollowsByActor({ 282 actor: did, 283 }) 284 .then(res => { 285 const dids = res.data.suggestions 286 .filter(a => !a.viewer?.following) 287 .map(a => a.did) 288 .slice(0, 8) 289 userActionHistory.followSuggestion(dids) 290 }) 291 } 292 }, 293 }) 294 295 const queueFollow = useCallback(() => { 296 // optimistically update 297 updateProfileShadow(queryClient, did, { 298 followingUri: 'pending', 299 }) 300 return queueToggle(true) 301 }, [queryClient, did, queueToggle]) 302 303 const queueUnfollow = useCallback(() => { 304 // optimistically update 305 updateProfileShadow(queryClient, did, { 306 followingUri: undefined, 307 }) 308 return queueToggle(false) 309 }, [queryClient, did, queueToggle]) 310 311 return [queueFollow, queueUnfollow] 312} 313 314function useProfileFollowMutation( 315 logContext: LogEvents['profile:follow']['logContext'], 316 profile: Shadow<bsky.profile.AnyProfileView>, 317) { 318 const {currentAccount} = useSession() 319 const agent = useAgent() 320 const queryClient = useQueryClient() 321 const {captureAction} = useProgressGuideControls() 322 323 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 324 mutationFn: async ({did}) => { 325 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined 326 if (currentAccount) { 327 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 328 } 329 captureAction(ProgressGuideAction.Follow) 330 logEvent('profile:follow', { 331 logContext, 332 didBecomeMutual: profile.viewer 333 ? Boolean(profile.viewer.followedBy) 334 : undefined, 335 followeeClout: 336 'followersCount' in profile 337 ? toClout(profile.followersCount) 338 : undefined, 339 followerClout: toClout(ownProfile?.followersCount), 340 }) 341 return await agent.follow(did) 342 }, 343 }) 344} 345 346function useProfileUnfollowMutation( 347 logContext: LogEvents['profile:unfollow']['logContext'], 348) { 349 const agent = useAgent() 350 return useMutation<void, Error, {did: string; followUri: string}>({ 351 mutationFn: async ({followUri}) => { 352 logEvent('profile:unfollow', {logContext}) 353 return await agent.deleteFollow(followUri) 354 }, 355 }) 356} 357 358export function useProfileMuteMutationQueue( 359 profile: Shadow<bsky.profile.AnyProfileView>, 360) { 361 const queryClient = useQueryClient() 362 const did = profile.did 363 const initialMuted = profile.viewer?.muted 364 const muteMutation = useProfileMuteMutation() 365 const unmuteMutation = useProfileUnmuteMutation() 366 367 const queueToggle = useToggleMutationQueue({ 368 initialState: initialMuted, 369 runMutation: async (_prevMuted, shouldMute) => { 370 if (shouldMute) { 371 await muteMutation.mutateAsync({ 372 did, 373 }) 374 return true 375 } else { 376 await unmuteMutation.mutateAsync({ 377 did, 378 }) 379 return false 380 } 381 }, 382 onSuccess(finalMuted) { 383 // finalize 384 updateProfileShadow(queryClient, did, {muted: finalMuted}) 385 }, 386 }) 387 388 const queueMute = useCallback(() => { 389 // optimistically update 390 updateProfileShadow(queryClient, did, { 391 muted: true, 392 }) 393 return queueToggle(true) 394 }, [queryClient, did, queueToggle]) 395 396 const queueUnmute = useCallback(() => { 397 // optimistically update 398 updateProfileShadow(queryClient, did, { 399 muted: false, 400 }) 401 return queueToggle(false) 402 }, [queryClient, did, queueToggle]) 403 404 return [queueMute, queueUnmute] 405} 406 407function useProfileMuteMutation() { 408 const queryClient = useQueryClient() 409 const agent = useAgent() 410 return useMutation<void, Error, {did: string}>({ 411 mutationFn: async ({did}) => { 412 await agent.mute(did) 413 }, 414 onSuccess() { 415 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 416 }, 417 }) 418} 419 420function useProfileUnmuteMutation() { 421 const queryClient = useQueryClient() 422 const agent = useAgent() 423 return useMutation<void, Error, {did: string}>({ 424 mutationFn: async ({did}) => { 425 await agent.unmute(did) 426 }, 427 onSuccess() { 428 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 429 }, 430 }) 431} 432 433export function useProfileBlockMutationQueue( 434 profile: Shadow<bsky.profile.AnyProfileView>, 435) { 436 const queryClient = useQueryClient() 437 const did = profile.did 438 const initialBlockingUri = profile.viewer?.blocking 439 const blockMutation = useProfileBlockMutation() 440 const unblockMutation = useProfileUnblockMutation() 441 442 const queueToggle = useToggleMutationQueue({ 443 initialState: initialBlockingUri, 444 runMutation: async (prevBlockUri, shouldFollow) => { 445 if (shouldFollow) { 446 const {uri} = await blockMutation.mutateAsync({ 447 did, 448 }) 449 return uri 450 } else { 451 if (prevBlockUri) { 452 await unblockMutation.mutateAsync({ 453 did, 454 blockUri: prevBlockUri, 455 }) 456 } 457 return undefined 458 } 459 }, 460 onSuccess(finalBlockingUri) { 461 // finalize 462 updateProfileShadow(queryClient, did, { 463 blockingUri: finalBlockingUri, 464 }) 465 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) 466 }, 467 }) 468 469 const queueBlock = useCallback(() => { 470 // optimistically update 471 updateProfileShadow(queryClient, did, { 472 blockingUri: 'pending', 473 }) 474 return queueToggle(true) 475 }, [queryClient, did, queueToggle]) 476 477 const queueUnblock = useCallback(() => { 478 // optimistically update 479 updateProfileShadow(queryClient, did, { 480 blockingUri: undefined, 481 }) 482 return queueToggle(false) 483 }, [queryClient, did, queueToggle]) 484 485 return [queueBlock, queueUnblock] 486} 487 488function useProfileBlockMutation() { 489 const {currentAccount} = useSession() 490 const agent = useAgent() 491 const queryClient = useQueryClient() 492 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 493 mutationFn: async ({did}) => { 494 if (!currentAccount) { 495 throw new Error('Not signed in') 496 } 497 return await agent.app.bsky.graph.block.create( 498 {repo: currentAccount.did}, 499 {subject: did, createdAt: new Date().toISOString()}, 500 ) 501 }, 502 onSuccess(_, {did}) { 503 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) 504 resetProfilePostsQueries(queryClient, did, 1000) 505 }, 506 }) 507} 508 509function useProfileUnblockMutation() { 510 const {currentAccount} = useSession() 511 const agent = useAgent() 512 const queryClient = useQueryClient() 513 return useMutation<void, Error, {did: string; blockUri: string}>({ 514 mutationFn: async ({blockUri}) => { 515 if (!currentAccount) { 516 throw new Error('Not signed in') 517 } 518 const {rkey} = new AtUri(blockUri) 519 await agent.app.bsky.graph.block.delete({ 520 repo: currentAccount.did, 521 rkey, 522 }) 523 }, 524 onSuccess(_, {did}) { 525 resetProfilePostsQueries(queryClient, did, 1000) 526 }, 527 }) 528} 529 530async function whenAppViewReady( 531 agent: BskyAgent, 532 actor: string, 533 fn: (res: AppBskyActorGetProfile.Response) => boolean, 534) { 535 await until( 536 5, // 5 tries 537 1e3, // 1s delay between tries 538 fn, 539 () => agent.app.bsky.actor.getProfile({actor}), 540 ) 541} 542 543export function* findAllProfilesInQueryData( 544 queryClient: QueryClient, 545 did: string, 546): Generator<AppBskyActorDefs.ProfileViewDetailed, void> { 547 const profileQueryDatas = 548 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({ 549 queryKey: [RQKEY_ROOT], 550 }) 551 for (const [_queryKey, queryData] of profileQueryDatas) { 552 if (!queryData) { 553 continue 554 } 555 if (queryData.did === did) { 556 yield queryData 557 } 558 } 559 const profilesQueryDatas = 560 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({ 561 queryKey: [profilesQueryKeyRoot], 562 }) 563 for (const [_queryKey, queryData] of profilesQueryDatas) { 564 if (!queryData) { 565 continue 566 } 567 for (let profile of queryData.profiles) { 568 if (profile.did === did) { 569 yield profile 570 } 571 } 572 } 573} 574 575export function findProfileQueryData( 576 queryClient: QueryClient, 577 did: string, 578): AppBskyActorDefs.ProfileViewDetailed | undefined { 579 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>( 580 RQKEY(did), 581 ) 582}