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 {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
26import {updateProfileShadow} from '#/state/cache/profile-shadow'
27import {type Shadow} from '#/state/cache/types'
28import {type ImageMeta} from '#/state/gallery'
29import {STALE} from '#/state/queries'
30import {resetProfilePostsQueries} from '#/state/queries/post-feed'
31import {RQKEY as PROFILE_FOLLOWS_RQKEY} from '#/state/queries/profile-follows'
32import {
33 unstableCacheProfileView,
34 useUnstableProfileViewCache,
35} from '#/state/queries/unstable-profile-cache'
36import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
37import {useAgent, useSession} from '#/state/session'
38import * as userActionHistory from '#/state/userActionHistory'
39import type * as bsky from '#/types/bsky'
40import {
41 ProgressGuideAction,
42 useProgressGuideControls,
43} from '../shell/progress-guide'
44import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
45import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
46import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
47
48export * from '#/state/queries/unstable-profile-cache'
49/**
50 * @deprecated use {@link unstableCacheProfileView} instead
51 */
52export const precacheProfile = unstableCacheProfileView
53
54const RQKEY_ROOT = 'profile'
55export const RQKEY = (did: string) => [RQKEY_ROOT, did]
56
57export const profilesQueryKeyRoot = 'profiles'
58export const profilesQueryKey = (handles: string[]) => [
59 profilesQueryKeyRoot,
60 handles,
61]
62
63export function useProfileQuery({
64 did,
65 staleTime = STALE.SECONDS.FIFTEEN,
66}: {
67 did: string | undefined
68 staleTime?: number
69}) {
70 const agent = useAgent()
71 const {getUnstableProfile} = useUnstableProfileViewCache()
72 return useQuery<AppBskyActorDefs.ProfileViewDetailed>({
73 // WARNING
74 // this staleTime is load-bearing
75 // if you remove it, the UI infinite-loops
76 // -prf
77 staleTime,
78 refetchOnWindowFocus: true,
79 queryKey: RQKEY(did ?? ''),
80 queryFn: async () => {
81 const res = await agent.getProfile({actor: did ?? ''})
82 return res.data
83 },
84 placeholderData: () => {
85 if (!did) return
86 return getUnstableProfile(did) as AppBskyActorDefs.ProfileViewDetailed
87 },
88 enabled: !!did,
89 })
90}
91
92export function useProfilesQuery({
93 handles,
94 maintainData,
95}: {
96 handles: string[]
97 maintainData?: boolean
98}) {
99 const agent = useAgent()
100 return useQuery({
101 staleTime: STALE.MINUTES.FIVE,
102 queryKey: profilesQueryKey(handles),
103 queryFn: async () => {
104 const res = await agent.getProfiles({actors: handles})
105 return res.data
106 },
107 placeholderData: maintainData ? keepPreviousData : undefined,
108 })
109}
110
111export function usePrefetchProfileQuery() {
112 const agent = useAgent()
113 const queryClient = useQueryClient()
114 const prefetchProfileQuery = useCallback(
115 async (did: string) => {
116 await queryClient.prefetchQuery({
117 staleTime: STALE.SECONDS.THIRTY,
118 queryKey: RQKEY(did),
119 queryFn: async () => {
120 const res = await agent.getProfile({actor: did || ''})
121 return res.data
122 },
123 })
124 },
125 [queryClient, agent],
126 )
127 return prefetchProfileQuery
128}
129
130interface ProfileUpdateParams {
131 profile: AppBskyActorDefs.ProfileViewDetailed
132 updates:
133 | Un$Typed<AppBskyActorProfile.Record>
134 | ((
135 existing: Un$Typed<AppBskyActorProfile.Record>,
136 ) => Un$Typed<AppBskyActorProfile.Record>)
137 newUserAvatar?: ImageMeta | undefined | null
138 newUserBanner?: ImageMeta | undefined | null
139 checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
140}
141export function useProfileUpdateMutation() {
142 const queryClient = useQueryClient()
143 const agent = useAgent()
144 const updateProfileVerificationCache = useUpdateProfileVerificationCache()
145 return useMutation<void, Error, ProfileUpdateParams>({
146 mutationFn: async ({
147 profile,
148 updates,
149 newUserAvatar,
150 newUserBanner,
151 checkCommitted,
152 }) => {
153 let newUserAvatarPromise:
154 | Promise<ComAtprotoRepoUploadBlob.Response>
155 | undefined
156 if (newUserAvatar) {
157 newUserAvatarPromise = uploadBlob(
158 agent,
159 newUserAvatar.path,
160 newUserAvatar.mime,
161 )
162 }
163 let newUserBannerPromise:
164 | Promise<ComAtprotoRepoUploadBlob.Response>
165 | undefined
166 if (newUserBanner) {
167 newUserBannerPromise = uploadBlob(
168 agent,
169 newUserBanner.path,
170 newUserBanner.mime,
171 )
172 }
173 await agent.upsertProfile(async existing => {
174 let next: Un$Typed<AppBskyActorProfile.Record> = existing || {}
175 if (typeof updates === 'function') {
176 next = updates(next)
177 } else {
178 next.displayName = updates.displayName
179 next.description = updates.description
180 if ('pinnedPost' in updates) {
181 next.pinnedPost = updates.pinnedPost
182 }
183 }
184 if (newUserAvatarPromise) {
185 const res = await newUserAvatarPromise
186 next.avatar = res.data.blob
187 } else if (newUserAvatar === null) {
188 next.avatar = undefined
189 }
190 if (newUserBannerPromise) {
191 const res = await newUserBannerPromise
192 next.banner = res.data.blob
193 } else if (newUserBanner === null) {
194 next.banner = undefined
195 }
196 return next
197 })
198 await whenAppViewReady(
199 agent,
200 profile.did,
201 checkCommitted ||
202 (res => {
203 if (typeof newUserAvatar !== 'undefined') {
204 if (newUserAvatar === null && res.data.avatar) {
205 // url hasnt cleared yet
206 return false
207 } else if (res.data.avatar === profile.avatar) {
208 // url hasnt changed yet
209 return false
210 }
211 }
212 if (typeof newUserBanner !== 'undefined') {
213 if (newUserBanner === null && res.data.banner) {
214 // url hasnt cleared yet
215 return false
216 } else if (res.data.banner === profile.banner) {
217 // url hasnt changed yet
218 return false
219 }
220 }
221 if (typeof updates === 'function') {
222 return true
223 }
224 return (
225 res.data.displayName === updates.displayName &&
226 res.data.description === updates.description
227 )
228 }),
229 )
230 },
231 async onSuccess(_, variables) {
232 // invalidate cache
233 queryClient.invalidateQueries({
234 queryKey: RQKEY(variables.profile.did),
235 })
236 queryClient.invalidateQueries({
237 queryKey: [profilesQueryKeyRoot, [variables.profile.did]],
238 })
239 await updateProfileVerificationCache({profile: variables.profile})
240 },
241 })
242}
243
244export function useProfileFollowMutationQueue(
245 profile: Shadow<bsky.profile.AnyProfileView>,
246 logContext: LogEvents['profile:follow']['logContext'] &
247 LogEvents['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: LogEvents['profile:follow']['logContext'],
368 profile: Shadow<bsky.profile.AnyProfileView>,
369 position?: number,
370 contextProfileDid?: string,
371) {
372 const {currentAccount} = useSession()
373 const agent = useAgent()
374 const queryClient = useQueryClient()
375 const {captureAction} = useProgressGuideControls()
376
377 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
378 mutationFn: async ({did}) => {
379 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
380 if (currentAccount) {
381 ownProfile = findProfileQueryData(queryClient, currentAccount.did)
382 }
383 captureAction(ProgressGuideAction.Follow)
384 logEvent('profile:follow', {
385 logContext,
386 didBecomeMutual: profile.viewer
387 ? Boolean(profile.viewer.followedBy)
388 : undefined,
389 followeeClout:
390 'followersCount' in profile
391 ? toClout(profile.followersCount)
392 : undefined,
393 followeeDid: did,
394 followerClout: toClout(ownProfile?.followersCount),
395 position,
396 contextProfileDid,
397 })
398 return await agent.follow(did)
399 },
400 })
401}
402
403function useProfileUnfollowMutation(
404 logContext: LogEvents['profile:unfollow']['logContext'],
405) {
406 const agent = useAgent()
407 return useMutation<void, Error, {did: string; followUri: string}>({
408 mutationFn: async ({followUri}) => {
409 logEvent('profile:unfollow', {logContext})
410 return await agent.deleteFollow(followUri)
411 },
412 })
413}
414
415export function useProfileMuteMutationQueue(
416 profile: Shadow<bsky.profile.AnyProfileView>,
417) {
418 const queryClient = useQueryClient()
419 const did = profile.did
420 const initialMuted = profile.viewer?.muted
421 const muteMutation = useProfileMuteMutation()
422 const unmuteMutation = useProfileUnmuteMutation()
423
424 const queueToggle = useToggleMutationQueue({
425 initialState: initialMuted,
426 runMutation: async (_prevMuted, shouldMute) => {
427 if (shouldMute) {
428 await muteMutation.mutateAsync({
429 did,
430 })
431 return true
432 } else {
433 await unmuteMutation.mutateAsync({
434 did,
435 })
436 return false
437 }
438 },
439 onSuccess(finalMuted) {
440 // finalize
441 updateProfileShadow(queryClient, did, {muted: finalMuted})
442 },
443 })
444
445 const queueMute = useCallback(() => {
446 // optimistically update
447 updateProfileShadow(queryClient, did, {
448 muted: true,
449 })
450 return queueToggle(true)
451 }, [queryClient, did, queueToggle])
452
453 const queueUnmute = useCallback(() => {
454 // optimistically update
455 updateProfileShadow(queryClient, did, {
456 muted: false,
457 })
458 return queueToggle(false)
459 }, [queryClient, did, queueToggle])
460
461 return [queueMute, queueUnmute]
462}
463
464function useProfileMuteMutation() {
465 const queryClient = useQueryClient()
466 const agent = useAgent()
467 return useMutation<void, Error, {did: string}>({
468 mutationFn: async ({did}) => {
469 await agent.mute(did)
470 },
471 onSuccess() {
472 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
473 },
474 })
475}
476
477function useProfileUnmuteMutation() {
478 const queryClient = useQueryClient()
479 const agent = useAgent()
480 return useMutation<void, Error, {did: string}>({
481 mutationFn: async ({did}) => {
482 await agent.unmute(did)
483 },
484 onSuccess() {
485 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
486 },
487 })
488}
489
490export function useProfileBlockMutationQueue(
491 profile: Shadow<bsky.profile.AnyProfileView>,
492) {
493 const queryClient = useQueryClient()
494 const did = profile.did
495 const initialBlockingUri = profile.viewer?.blocking
496 const blockMutation = useProfileBlockMutation()
497 const unblockMutation = useProfileUnblockMutation()
498
499 const queueToggle = useToggleMutationQueue({
500 initialState: initialBlockingUri,
501 runMutation: async (prevBlockUri, shouldFollow) => {
502 if (shouldFollow) {
503 const {uri} = await blockMutation.mutateAsync({
504 did,
505 })
506 return uri
507 } else {
508 if (prevBlockUri) {
509 await unblockMutation.mutateAsync({
510 did,
511 blockUri: prevBlockUri,
512 })
513 }
514 return undefined
515 }
516 },
517 onSuccess(finalBlockingUri) {
518 // finalize
519 updateProfileShadow(queryClient, did, {
520 blockingUri: finalBlockingUri,
521 })
522 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
523 },
524 })
525
526 const queueBlock = useCallback(() => {
527 // optimistically update
528 updateProfileShadow(queryClient, did, {
529 blockingUri: 'pending',
530 })
531 return queueToggle(true)
532 }, [queryClient, did, queueToggle])
533
534 const queueUnblock = useCallback(() => {
535 // optimistically update
536 updateProfileShadow(queryClient, did, {
537 blockingUri: undefined,
538 })
539 return queueToggle(false)
540 }, [queryClient, did, queueToggle])
541
542 return [queueBlock, queueUnblock]
543}
544
545function useProfileBlockMutation() {
546 const {currentAccount} = useSession()
547 const agent = useAgent()
548 const queryClient = useQueryClient()
549 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
550 mutationFn: async ({did}) => {
551 if (!currentAccount) {
552 throw new Error('Not signed in')
553 }
554 return await agent.app.bsky.graph.block.create(
555 {repo: currentAccount.did},
556 {subject: did, createdAt: new Date().toISOString()},
557 )
558 },
559 onSuccess(_, {did}) {
560 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
561 resetProfilePostsQueries(queryClient, did, 1000)
562 },
563 })
564}
565
566function useProfileUnblockMutation() {
567 const {currentAccount} = useSession()
568 const agent = useAgent()
569 const queryClient = useQueryClient()
570 return useMutation<void, Error, {did: string; blockUri: string}>({
571 mutationFn: async ({blockUri}) => {
572 if (!currentAccount) {
573 throw new Error('Not signed in')
574 }
575 const {rkey} = new AtUri(blockUri)
576 await agent.app.bsky.graph.block.delete({
577 repo: currentAccount.did,
578 rkey,
579 })
580 },
581 onSuccess(_, {did}) {
582 resetProfilePostsQueries(queryClient, did, 1000)
583 },
584 })
585}
586
587async function whenAppViewReady(
588 agent: BskyAgent,
589 actor: string,
590 fn: (res: AppBskyActorGetProfile.Response) => boolean,
591) {
592 await until(
593 5, // 5 tries
594 1e3, // 1s delay between tries
595 fn,
596 () => agent.app.bsky.actor.getProfile({actor}),
597 )
598}
599
600export function* findAllProfilesInQueryData(
601 queryClient: QueryClient,
602 did: string,
603): Generator<AppBskyActorDefs.ProfileViewDetailed, void> {
604 const profileQueryDatas =
605 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({
606 queryKey: [RQKEY_ROOT],
607 })
608 for (const [_queryKey, queryData] of profileQueryDatas) {
609 if (!queryData) {
610 continue
611 }
612 if (queryData.did === did) {
613 yield queryData
614 }
615 }
616 const profilesQueryDatas =
617 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({
618 queryKey: [profilesQueryKeyRoot],
619 })
620 for (const [_queryKey, queryData] of profilesQueryDatas) {
621 if (!queryData) {
622 continue
623 }
624 for (let profile of queryData.profiles) {
625 if (profile.did === did) {
626 yield profile
627 }
628 }
629 }
630}
631
632export function findProfileQueryData(
633 queryClient: QueryClient,
634 did: string,
635): AppBskyActorDefs.ProfileViewDetailed | undefined {
636 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>(
637 RQKEY(did),
638 )
639}