Bluesky app fork with some witchin' additions 馃挮
at post-text-option 594 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 position?: number, 246 contextProfileDid?: string, 247) { 248 const agent = useAgent() 249 const queryClient = useQueryClient() 250 const did = profile.did 251 const initialFollowingUri = profile.viewer?.following 252 const followMutation = useProfileFollowMutation( 253 logContext, 254 profile, 255 position, 256 contextProfileDid, 257 ) 258 const unfollowMutation = useProfileUnfollowMutation(logContext) 259 260 const queueToggle = useToggleMutationQueue({ 261 initialState: initialFollowingUri, 262 runMutation: async (prevFollowingUri, shouldFollow) => { 263 if (shouldFollow) { 264 const {uri} = await followMutation.mutateAsync({ 265 did, 266 }) 267 userActionHistory.follow([did]) 268 return uri 269 } else { 270 if (prevFollowingUri) { 271 await unfollowMutation.mutateAsync({ 272 did, 273 followUri: prevFollowingUri, 274 }) 275 userActionHistory.unfollow([did]) 276 } 277 return undefined 278 } 279 }, 280 onSuccess(finalFollowingUri) { 281 // finalize 282 updateProfileShadow(queryClient, did, { 283 followingUri: finalFollowingUri, 284 }) 285 286 if (finalFollowingUri) { 287 agent.app.bsky.graph 288 .getSuggestedFollowsByActor({ 289 actor: did, 290 }) 291 .then(res => { 292 const dids = res.data.suggestions 293 .filter(a => !a.viewer?.following) 294 .map(a => a.did) 295 .slice(0, 8) 296 userActionHistory.followSuggestion(dids) 297 }) 298 } 299 }, 300 }) 301 302 const queueFollow = useCallback(() => { 303 // optimistically update 304 updateProfileShadow(queryClient, did, { 305 followingUri: 'pending', 306 }) 307 return queueToggle(true) 308 }, [queryClient, did, queueToggle]) 309 310 const queueUnfollow = useCallback(() => { 311 // optimistically update 312 updateProfileShadow(queryClient, did, { 313 followingUri: undefined, 314 }) 315 return queueToggle(false) 316 }, [queryClient, did, queueToggle]) 317 318 return [queueFollow, queueUnfollow] 319} 320 321function useProfileFollowMutation( 322 logContext: LogEvents['profile:follow']['logContext'], 323 profile: Shadow<bsky.profile.AnyProfileView>, 324 position?: number, 325 contextProfileDid?: string, 326) { 327 const {currentAccount} = useSession() 328 const agent = useAgent() 329 const queryClient = useQueryClient() 330 const {captureAction} = useProgressGuideControls() 331 332 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 333 mutationFn: async ({did}) => { 334 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined 335 if (currentAccount) { 336 ownProfile = findProfileQueryData(queryClient, currentAccount.did) 337 } 338 captureAction(ProgressGuideAction.Follow) 339 logEvent('profile:follow', { 340 logContext, 341 didBecomeMutual: profile.viewer 342 ? Boolean(profile.viewer.followedBy) 343 : undefined, 344 followeeClout: 345 'followersCount' in profile 346 ? toClout(profile.followersCount) 347 : undefined, 348 followeeDid: did, 349 followerClout: toClout(ownProfile?.followersCount), 350 position, 351 contextProfileDid, 352 }) 353 return await agent.follow(did) 354 }, 355 }) 356} 357 358function useProfileUnfollowMutation( 359 logContext: LogEvents['profile:unfollow']['logContext'], 360) { 361 const agent = useAgent() 362 return useMutation<void, Error, {did: string; followUri: string}>({ 363 mutationFn: async ({followUri}) => { 364 logEvent('profile:unfollow', {logContext}) 365 return await agent.deleteFollow(followUri) 366 }, 367 }) 368} 369 370export function useProfileMuteMutationQueue( 371 profile: Shadow<bsky.profile.AnyProfileView>, 372) { 373 const queryClient = useQueryClient() 374 const did = profile.did 375 const initialMuted = profile.viewer?.muted 376 const muteMutation = useProfileMuteMutation() 377 const unmuteMutation = useProfileUnmuteMutation() 378 379 const queueToggle = useToggleMutationQueue({ 380 initialState: initialMuted, 381 runMutation: async (_prevMuted, shouldMute) => { 382 if (shouldMute) { 383 await muteMutation.mutateAsync({ 384 did, 385 }) 386 return true 387 } else { 388 await unmuteMutation.mutateAsync({ 389 did, 390 }) 391 return false 392 } 393 }, 394 onSuccess(finalMuted) { 395 // finalize 396 updateProfileShadow(queryClient, did, {muted: finalMuted}) 397 }, 398 }) 399 400 const queueMute = useCallback(() => { 401 // optimistically update 402 updateProfileShadow(queryClient, did, { 403 muted: true, 404 }) 405 return queueToggle(true) 406 }, [queryClient, did, queueToggle]) 407 408 const queueUnmute = useCallback(() => { 409 // optimistically update 410 updateProfileShadow(queryClient, did, { 411 muted: false, 412 }) 413 return queueToggle(false) 414 }, [queryClient, did, queueToggle]) 415 416 return [queueMute, queueUnmute] 417} 418 419function useProfileMuteMutation() { 420 const queryClient = useQueryClient() 421 const agent = useAgent() 422 return useMutation<void, Error, {did: string}>({ 423 mutationFn: async ({did}) => { 424 await agent.mute(did) 425 }, 426 onSuccess() { 427 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 428 }, 429 }) 430} 431 432function useProfileUnmuteMutation() { 433 const queryClient = useQueryClient() 434 const agent = useAgent() 435 return useMutation<void, Error, {did: string}>({ 436 mutationFn: async ({did}) => { 437 await agent.unmute(did) 438 }, 439 onSuccess() { 440 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()}) 441 }, 442 }) 443} 444 445export function useProfileBlockMutationQueue( 446 profile: Shadow<bsky.profile.AnyProfileView>, 447) { 448 const queryClient = useQueryClient() 449 const did = profile.did 450 const initialBlockingUri = profile.viewer?.blocking 451 const blockMutation = useProfileBlockMutation() 452 const unblockMutation = useProfileUnblockMutation() 453 454 const queueToggle = useToggleMutationQueue({ 455 initialState: initialBlockingUri, 456 runMutation: async (prevBlockUri, shouldFollow) => { 457 if (shouldFollow) { 458 const {uri} = await blockMutation.mutateAsync({ 459 did, 460 }) 461 return uri 462 } else { 463 if (prevBlockUri) { 464 await unblockMutation.mutateAsync({ 465 did, 466 blockUri: prevBlockUri, 467 }) 468 } 469 return undefined 470 } 471 }, 472 onSuccess(finalBlockingUri) { 473 // finalize 474 updateProfileShadow(queryClient, did, { 475 blockingUri: finalBlockingUri, 476 }) 477 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]}) 478 }, 479 }) 480 481 const queueBlock = useCallback(() => { 482 // optimistically update 483 updateProfileShadow(queryClient, did, { 484 blockingUri: 'pending', 485 }) 486 return queueToggle(true) 487 }, [queryClient, did, queueToggle]) 488 489 const queueUnblock = useCallback(() => { 490 // optimistically update 491 updateProfileShadow(queryClient, did, { 492 blockingUri: undefined, 493 }) 494 return queueToggle(false) 495 }, [queryClient, did, queueToggle]) 496 497 return [queueBlock, queueUnblock] 498} 499 500function useProfileBlockMutation() { 501 const {currentAccount} = useSession() 502 const agent = useAgent() 503 const queryClient = useQueryClient() 504 return useMutation<{uri: string; cid: string}, Error, {did: string}>({ 505 mutationFn: async ({did}) => { 506 if (!currentAccount) { 507 throw new Error('Not signed in') 508 } 509 return await agent.app.bsky.graph.block.create( 510 {repo: currentAccount.did}, 511 {subject: did, createdAt: new Date().toISOString()}, 512 ) 513 }, 514 onSuccess(_, {did}) { 515 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()}) 516 resetProfilePostsQueries(queryClient, did, 1000) 517 }, 518 }) 519} 520 521function useProfileUnblockMutation() { 522 const {currentAccount} = useSession() 523 const agent = useAgent() 524 const queryClient = useQueryClient() 525 return useMutation<void, Error, {did: string; blockUri: string}>({ 526 mutationFn: async ({blockUri}) => { 527 if (!currentAccount) { 528 throw new Error('Not signed in') 529 } 530 const {rkey} = new AtUri(blockUri) 531 await agent.app.bsky.graph.block.delete({ 532 repo: currentAccount.did, 533 rkey, 534 }) 535 }, 536 onSuccess(_, {did}) { 537 resetProfilePostsQueries(queryClient, did, 1000) 538 }, 539 }) 540} 541 542async function whenAppViewReady( 543 agent: BskyAgent, 544 actor: string, 545 fn: (res: AppBskyActorGetProfile.Response) => boolean, 546) { 547 await until( 548 5, // 5 tries 549 1e3, // 1s delay between tries 550 fn, 551 () => agent.app.bsky.actor.getProfile({actor}), 552 ) 553} 554 555export function* findAllProfilesInQueryData( 556 queryClient: QueryClient, 557 did: string, 558): Generator<AppBskyActorDefs.ProfileViewDetailed, void> { 559 const profileQueryDatas = 560 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({ 561 queryKey: [RQKEY_ROOT], 562 }) 563 for (const [_queryKey, queryData] of profileQueryDatas) { 564 if (!queryData) { 565 continue 566 } 567 if (queryData.did === did) { 568 yield queryData 569 } 570 } 571 const profilesQueryDatas = 572 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({ 573 queryKey: [profilesQueryKeyRoot], 574 }) 575 for (const [_queryKey, queryData] of profilesQueryDatas) { 576 if (!queryData) { 577 continue 578 } 579 for (let profile of queryData.profiles) { 580 if (profile.did === did) { 581 yield profile 582 } 583 } 584 } 585} 586 587export function findProfileQueryData( 588 queryClient: QueryClient, 589 did: string, 590): AppBskyActorDefs.ProfileViewDetailed | undefined { 591 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>( 592 RQKEY(did), 593 ) 594}