mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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}