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