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