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