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