mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

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