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