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

Configure Feed

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

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