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

Configure Feed

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

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