Bluesky app fork with some witchin' additions 馃挮
at readme-update 641 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 } 185 if (newUserAvatarPromise) { 186 const res = await newUserAvatarPromise 187 next.avatar = res.data.blob 188 } else if (newUserAvatar === null) { 189 next.avatar = undefined 190 } 191 if (newUserBannerPromise) { 192 const res = await newUserBannerPromise 193 next.banner = res.data.blob 194 } else if (newUserBanner === null) { 195 next.banner = undefined 196 } 197 return next 198 }) 199 await whenAppViewReady( 200 agent, 201 profile.did, 202 checkCommitted || 203 (res => { 204 if (typeof newUserAvatar !== 'undefined') { 205 if (newUserAvatar === null && res.data.avatar) { 206 // url hasnt cleared yet 207 return false 208 } else if (res.data.avatar === profile.avatar) { 209 // url hasnt changed yet 210 return false 211 } 212 } 213 if (typeof newUserBanner !== 'undefined') { 214 if (newUserBanner === null && res.data.banner) { 215 // url hasnt cleared yet 216 return false 217 } else if (res.data.banner === profile.banner) { 218 // url hasnt changed yet 219 return false 220 } 221 } 222 if (typeof updates === 'function') { 223 return true 224 } 225 return ( 226 res.data.displayName === updates.displayName && 227 res.data.description === updates.description 228 ) 229 }), 230 ) 231 }, 232 async onSuccess(_, variables) { 233 // invalidate cache 234 queryClient.invalidateQueries({ 235 queryKey: RQKEY(variables.profile.did), 236 }) 237 queryClient.invalidateQueries({ 238 queryKey: [profilesQueryKeyRoot, [variables.profile.did]], 239 }) 240 await updateProfileVerificationCache({profile: variables.profile}) 241 }, 242 }) 243} 244 245export function useProfileFollowMutationQueue( 246 profile: Shadow<bsky.profile.AnyProfileView>, 247 logContext: Metrics['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: Metrics['profile:follow']['logContext'], 368 profile: Shadow<bsky.profile.AnyProfileView>, 369 position?: number, 370 contextProfileDid?: string, 371) { 372 const ax = useAnalytics() 373 const {currentAccount} = useSession() 374 const agent = useAgent() 375 const queryClient = useQueryClient() 376 const {captureAction} = useProgressGuideControls() 377 378 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 379 mutationFn: async ({did}) => { 380 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined 381 if (currentAccount) { 382 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 383 } 384 captureAction(ProgressGuideAction.Follow) 385 ax.metric('profile:follow', { 386 logContext, 387 didBecomeMutual: profile.viewer 388 ? Boolean(profile.viewer.followedBy) 389 : undefined, 390 followeeClout: 391 'followersCount' in profile 392 ? toClout(profile.followersCount) 393 : undefined, 394 followeeDid: did, 395 followerClout: toClout(ownProfile?.followersCount), 396 position, 397 contextProfileDid, 398 }) 399 return await agent.follow(did) 400 }, 401 }) 402} 403 404function useProfileUnfollowMutation( 405 logContext: Metrics['profile:unfollow']['logContext'], 406) { 407 const ax = useAnalytics() 408 const agent = useAgent() 409 return useMutation<void, Error, {did: string; followUri: string}>({ 410 mutationFn: async ({followUri}) => { 411 ax.metric('profile:unfollow', {logContext}) 412 return await agent.deleteFollow(followUri) 413 }, 414 }) 415} 416 417export function useProfileMuteMutationQueue( 418 profile: Shadow<bsky.profile.AnyProfileView>, 419) { 420 const queryClient = useQueryClient() 421 const did = profile.did 422 const initialMuted = profile.viewer?.muted 423 const muteMutation = useProfileMuteMutation() 424 const unmuteMutation = useProfileUnmuteMutation() 425 426 const queueToggle = useToggleMutationQueue({ 427 initialState: initialMuted, 428 runMutation: async (_prevMuted, shouldMute) => { 429 if (shouldMute) { 430 await muteMutation.mutateAsync({ 431 did, 432 }) 433 return true 434 } else { 435 await unmuteMutation.mutateAsync({ 436 did, 437 }) 438 return false 439 } 440 }, 441 onSuccess(finalMuted) { 442 // finalize 443 updateProfileShadow(queryClient, did, {muted: finalMuted}) 444 }, 445 }) 446 447 const queueMute = useCallback(() => { 448 // optimistically update 449 updateProfileShadow(queryClient, did, { 450 muted: true, 451 }) 452 return queueToggle(true) 453 }, [queryClient, did, queueToggle]) 454 455 const queueUnmute = useCallback(() => { 456 // optimistically update 457 updateProfileShadow(queryClient, did, { 458 muted: false, 459 }) 460 return queueToggle(false) 461 }, [queryClient, did, queueToggle]) 462 463 return [queueMute, queueUnmute] 464} 465 466function useProfileMuteMutation() { 467 const queryClient = useQueryClient() 468 const agent = useAgent() 469 return useMutation<void, Error, {did: string}>({ 470 mutationFn: async ({did}) => { 471 await agent.mute(did) 472 }, 473 onSuccess() { 474 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 475 }, 476 }) 477} 478 479function useProfileUnmuteMutation() { 480 const queryClient = useQueryClient() 481 const agent = useAgent() 482 return useMutation<void, Error, {did: string}>({ 483 mutationFn: async ({did}) => { 484 await agent.unmute(did) 485 }, 486 onSuccess() { 487 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 488 }, 489 }) 490} 491 492export function useProfileBlockMutationQueue( 493 profile: Shadow<bsky.profile.AnyProfileView>, 494) { 495 const queryClient = useQueryClient() 496 const did = profile.did 497 const initialBlockingUri = profile.viewer?.blocking 498 const blockMutation = useProfileBlockMutation() 499 const unblockMutation = useProfileUnblockMutation() 500 501 const queueToggle = useToggleMutationQueue({ 502 initialState: initialBlockingUri, 503 runMutation: async (prevBlockUri, shouldFollow) => { 504 if (shouldFollow) { 505 const {uri} = await blockMutation.mutateAsync({ 506 did, 507 }) 508 return uri 509 } else { 510 if (prevBlockUri) { 511 await unblockMutation.mutateAsync({ 512 did, 513 blockUri: prevBlockUri, 514 }) 515 } 516 return undefined 517 } 518 }, 519 onSuccess(finalBlockingUri) { 520 // finalize 521 updateProfileShadow(queryClient, did, { 522 blockingUri: finalBlockingUri, 523 }) 524 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) 525 }, 526 }) 527 528 const queueBlock = useCallback(() => { 529 // optimistically update 530 updateProfileShadow(queryClient, did, { 531 blockingUri: 'pending', 532 }) 533 return queueToggle(true) 534 }, [queryClient, did, queueToggle]) 535 536 const queueUnblock = useCallback(() => { 537 // optimistically update 538 updateProfileShadow(queryClient, did, { 539 blockingUri: undefined, 540 }) 541 return queueToggle(false) 542 }, [queryClient, did, queueToggle]) 543 544 return [queueBlock, queueUnblock] 545} 546 547function useProfileBlockMutation() { 548 const {currentAccount} = useSession() 549 const agent = useAgent() 550 const queryClient = useQueryClient() 551 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 552 mutationFn: async ({did}) => { 553 if (!currentAccount) { 554 throw new Error('Not signed in') 555 } 556 return await agent.app.bsky.graph.block.create( 557 {repo: currentAccount.did}, 558 {subject: did, createdAt: new Date().toISOString()}, 559 ) 560 }, 561 onSuccess(_, {did}) { 562 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) 563 resetProfilePostsQueries(queryClient, did, 1000) 564 }, 565 }) 566} 567 568function useProfileUnblockMutation() { 569 const {currentAccount} = useSession() 570 const agent = useAgent() 571 const queryClient = useQueryClient() 572 return useMutation<void, Error, {did: string; blockUri: string}>({ 573 mutationFn: async ({blockUri}) => { 574 if (!currentAccount) { 575 throw new Error('Not signed in') 576 } 577 const {rkey} = new AtUri(blockUri) 578 await agent.app.bsky.graph.block.delete({ 579 repo: currentAccount.did, 580 rkey, 581 }) 582 }, 583 onSuccess(_, {did}) { 584 resetProfilePostsQueries(queryClient, did, 1000) 585 }, 586 }) 587} 588 589async function whenAppViewReady( 590 agent: BskyAgent, 591 actor: string, 592 fn: (res: AppBskyActorGetProfile.Response) => boolean, 593) { 594 await until( 595 5, // 5 tries 596 1e3, // 1s delay between tries 597 fn, 598 () => agent.app.bsky.actor.getProfile({actor}), 599 ) 600} 601 602export function* findAllProfilesInQueryData( 603 queryClient: QueryClient, 604 did: string, 605): Generator<AppBskyActorDefs.ProfileViewDetailed, void> { 606 const profileQueryDatas = 607 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({ 608 queryKey: [RQKEY_ROOT], 609 }) 610 for (const [_queryKey, queryData] of profileQueryDatas) { 611 if (!queryData) { 612 continue 613 } 614 if (queryData.did === did) { 615 yield queryData 616 } 617 } 618 const profilesQueryDatas = 619 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({ 620 queryKey: [profilesQueryKeyRoot], 621 }) 622 for (const [_queryKey, queryData] of profilesQueryDatas) { 623 if (!queryData) { 624 continue 625 } 626 for (let profile of queryData.profiles) { 627 if (profile.did === did) { 628 yield profile 629 } 630 } 631 } 632} 633 634export function findProfileQueryData( 635 queryClient: QueryClient, 636 did: string, 637): AppBskyActorDefs.ProfileViewDetailed | undefined { 638 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>( 639 RQKEY(did), 640 ) 641}