forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}