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 AtUri,
8 type BskyAgent,
9 type ComAtprotoRepoUploadBlob,
10 type Un$Typed,
11} from '@atproto/api'
12import {
13 keepPreviousData,
14 type QueryClient,
15 useMutation,
16 useQuery,
17 useQueryClient,
18} from '@tanstack/react-query'
19
20import {uploadBlob} from '#/lib/api'
21import {until} from '#/lib/async/until'
22import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
23import {logEvent, type LogEvents, toClout} from '#/lib/statsig/statsig'
24import {updateProfileShadow} from '#/state/cache/profile-shadow'
25import {type Shadow} from '#/state/cache/types'
26import {type ImageMeta} from '#/state/gallery'
27import {STALE} from '#/state/queries'
28import {resetProfilePostsQueries} from '#/state/queries/post-feed'
29import {
30 unstableCacheProfileView,
31 useUnstableProfileViewCache,
32} from '#/state/queries/unstable-profile-cache'
33import {useUpdateProfileVerificationCache} from '#/state/queries/verification/useUpdateProfileVerificationCache'
34import {useAgent, useSession} from '#/state/session'
35import * as userActionHistory from '#/state/userActionHistory'
36import type * as bsky from '#/types/bsky'
37import {
38 ProgressGuideAction,
39 useProgressGuideControls,
40} from '../shell/progress-guide'
41import {RQKEY_ROOT as RQKEY_LIST_CONVOS} from './messages/list-conversations'
42import {RQKEY as RQKEY_MY_BLOCKED} from './my-blocked-accounts'
43import {RQKEY as RQKEY_MY_MUTED} from './my-muted-accounts'
44
45export * from '#/state/queries/unstable-profile-cache'
46/**
47 * @deprecated use {@link unstableCacheProfileView} instead
48 */
49export const precacheProfile = unstableCacheProfileView
50
51const RQKEY_ROOT = 'profile'
52export const RQKEY = (did: string) => [RQKEY_ROOT, did]
53
54export const profilesQueryKeyRoot = 'profiles'
55export const profilesQueryKey = (handles: string[]) => [
56 profilesQueryKeyRoot,
57 handles,
58]
59
60export function useProfileQuery({
61 did,
62 staleTime = STALE.SECONDS.FIFTEEN,
63}: {
64 did: string | undefined
65 staleTime?: number
66}) {
67 const agent = useAgent()
68 const {getUnstableProfile} = useUnstableProfileViewCache()
69 return useQuery<AppBskyActorDefs.ProfileViewDetailed>({
70 // WARNING
71 // this staleTime is load-bearing
72 // if you remove it, the UI infinite-loops
73 // -prf
74 staleTime,
75 refetchOnWindowFocus: true,
76 queryKey: RQKEY(did ?? ''),
77 queryFn: async () => {
78 const res = await agent.getProfile({actor: did ?? ''})
79 return res.data
80 },
81 placeholderData: () => {
82 if (!did) return
83 return getUnstableProfile(did) as AppBskyActorDefs.ProfileViewDetailed
84 },
85 enabled: !!did,
86 })
87}
88
89export function useProfilesQuery({
90 handles,
91 maintainData,
92}: {
93 handles: string[]
94 maintainData?: boolean
95}) {
96 const agent = useAgent()
97 return useQuery({
98 staleTime: STALE.MINUTES.FIVE,
99 queryKey: profilesQueryKey(handles),
100 queryFn: async () => {
101 const res = await agent.getProfiles({actors: handles})
102 return res.data
103 },
104 placeholderData: maintainData ? keepPreviousData : undefined,
105 })
106}
107
108export function usePrefetchProfileQuery() {
109 const agent = useAgent()
110 const queryClient = useQueryClient()
111 const prefetchProfileQuery = useCallback(
112 async (did: string) => {
113 await queryClient.prefetchQuery({
114 staleTime: STALE.SECONDS.THIRTY,
115 queryKey: RQKEY(did),
116 queryFn: async () => {
117 const res = await agent.getProfile({actor: did || ''})
118 return res.data
119 },
120 })
121 },
122 [queryClient, agent],
123 )
124 return prefetchProfileQuery
125}
126
127interface ProfileUpdateParams {
128 profile: AppBskyActorDefs.ProfileViewDetailed
129 updates:
130 | Un$Typed<AppBskyActorProfile.Record>
131 | ((
132 existing: Un$Typed<AppBskyActorProfile.Record>,
133 ) => Un$Typed<AppBskyActorProfile.Record>)
134 newUserAvatar?: ImageMeta | undefined | null
135 newUserBanner?: ImageMeta | undefined | null
136 checkCommitted?: (res: AppBskyActorGetProfile.Response) => boolean
137}
138export function useProfileUpdateMutation() {
139 const queryClient = useQueryClient()
140 const agent = useAgent()
141 const updateProfileVerificationCache = useUpdateProfileVerificationCache()
142 return useMutation<void, Error, ProfileUpdateParams>({
143 mutationFn: async ({
144 profile,
145 updates,
146 newUserAvatar,
147 newUserBanner,
148 checkCommitted,
149 }) => {
150 let newUserAvatarPromise:
151 | Promise<ComAtprotoRepoUploadBlob.Response>
152 | undefined
153 if (newUserAvatar) {
154 newUserAvatarPromise = uploadBlob(
155 agent,
156 newUserAvatar.path,
157 newUserAvatar.mime,
158 )
159 }
160 let newUserBannerPromise:
161 | Promise<ComAtprotoRepoUploadBlob.Response>
162 | undefined
163 if (newUserBanner) {
164 newUserBannerPromise = uploadBlob(
165 agent,
166 newUserBanner.path,
167 newUserBanner.mime,
168 )
169 }
170 await agent.upsertProfile(async existing => {
171 let next: Un$Typed<AppBskyActorProfile.Record> = existing || {}
172 if (typeof updates === 'function') {
173 next = updates(next)
174 } else {
175 next.displayName = updates.displayName
176 next.description = updates.description
177 if ('pinnedPost' in updates) {
178 next.pinnedPost = updates.pinnedPost
179 }
180 }
181 if (newUserAvatarPromise) {
182 const res = await newUserAvatarPromise
183 next.avatar = res.data.blob
184 } else if (newUserAvatar === null) {
185 next.avatar = undefined
186 }
187 if (newUserBannerPromise) {
188 const res = await newUserBannerPromise
189 next.banner = res.data.blob
190 } else if (newUserBanner === null) {
191 next.banner = undefined
192 }
193 return next
194 })
195 await whenAppViewReady(
196 agent,
197 profile.did,
198 checkCommitted ||
199 (res => {
200 if (typeof newUserAvatar !== 'undefined') {
201 if (newUserAvatar === null && res.data.avatar) {
202 // url hasnt cleared yet
203 return false
204 } else if (res.data.avatar === profile.avatar) {
205 // url hasnt changed yet
206 return false
207 }
208 }
209 if (typeof newUserBanner !== 'undefined') {
210 if (newUserBanner === null && res.data.banner) {
211 // url hasnt cleared yet
212 return false
213 } else if (res.data.banner === profile.banner) {
214 // url hasnt changed yet
215 return false
216 }
217 }
218 if (typeof updates === 'function') {
219 return true
220 }
221 return (
222 res.data.displayName === updates.displayName &&
223 res.data.description === updates.description
224 )
225 }),
226 )
227 },
228 async onSuccess(_, variables) {
229 // invalidate cache
230 queryClient.invalidateQueries({
231 queryKey: RQKEY(variables.profile.did),
232 })
233 queryClient.invalidateQueries({
234 queryKey: [profilesQueryKeyRoot, [variables.profile.did]],
235 })
236 await updateProfileVerificationCache({profile: variables.profile})
237 },
238 })
239}
240
241export function useProfileFollowMutationQueue(
242 profile: Shadow<bsky.profile.AnyProfileView>,
243 logContext: LogEvents['profile:follow']['logContext'] &
244 LogEvents['profile:follow']['logContext'],
245 position?: number,
246 contextProfileDid?: string,
247) {
248 const agent = useAgent()
249 const queryClient = useQueryClient()
250 const did = profile.did
251 const initialFollowingUri = profile.viewer?.following
252 const followMutation = useProfileFollowMutation(
253 logContext,
254 profile,
255 position,
256 contextProfileDid,
257 )
258 const unfollowMutation = useProfileUnfollowMutation(logContext)
259
260 const queueToggle = useToggleMutationQueue({
261 initialState: initialFollowingUri,
262 runMutation: async (prevFollowingUri, shouldFollow) => {
263 if (shouldFollow) {
264 const {uri} = await followMutation.mutateAsync({
265 did,
266 })
267 userActionHistory.follow([did])
268 return uri
269 } else {
270 if (prevFollowingUri) {
271 await unfollowMutation.mutateAsync({
272 did,
273 followUri: prevFollowingUri,
274 })
275 userActionHistory.unfollow([did])
276 }
277 return undefined
278 }
279 },
280 onSuccess(finalFollowingUri) {
281 // finalize
282 updateProfileShadow(queryClient, did, {
283 followingUri: finalFollowingUri,
284 })
285
286 if (finalFollowingUri) {
287 agent.app.bsky.graph
288 .getSuggestedFollowsByActor({
289 actor: did,
290 })
291 .then(res => {
292 const dids = res.data.suggestions
293 .filter(a => !a.viewer?.following)
294 .map(a => a.did)
295 .slice(0, 8)
296 userActionHistory.followSuggestion(dids)
297 })
298 }
299 },
300 })
301
302 const queueFollow = useCallback(() => {
303 // optimistically update
304 updateProfileShadow(queryClient, did, {
305 followingUri: 'pending',
306 })
307 return queueToggle(true)
308 }, [queryClient, did, queueToggle])
309
310 const queueUnfollow = useCallback(() => {
311 // optimistically update
312 updateProfileShadow(queryClient, did, {
313 followingUri: undefined,
314 })
315 return queueToggle(false)
316 }, [queryClient, did, queueToggle])
317
318 return [queueFollow, queueUnfollow]
319}
320
321function useProfileFollowMutation(
322 logContext: LogEvents['profile:follow']['logContext'],
323 profile: Shadow<bsky.profile.AnyProfileView>,
324 position?: number,
325 contextProfileDid?: string,
326) {
327 const {currentAccount} = useSession()
328 const agent = useAgent()
329 const queryClient = useQueryClient()
330 const {captureAction} = useProgressGuideControls()
331
332 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
333 mutationFn: async ({did}) => {
334 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
335 if (currentAccount) {
336 ownProfile = findProfileQueryData(queryClient, currentAccount.did)
337 }
338 captureAction(ProgressGuideAction.Follow)
339 logEvent('profile:follow', {
340 logContext,
341 didBecomeMutual: profile.viewer
342 ? Boolean(profile.viewer.followedBy)
343 : undefined,
344 followeeClout:
345 'followersCount' in profile
346 ? toClout(profile.followersCount)
347 : undefined,
348 followeeDid: did,
349 followerClout: toClout(ownProfile?.followersCount),
350 position,
351 contextProfileDid,
352 })
353 return await agent.follow(did)
354 },
355 })
356}
357
358function useProfileUnfollowMutation(
359 logContext: LogEvents['profile:unfollow']['logContext'],
360) {
361 const agent = useAgent()
362 return useMutation<void, Error, {did: string; followUri: string}>({
363 mutationFn: async ({followUri}) => {
364 logEvent('profile:unfollow', {logContext})
365 return await agent.deleteFollow(followUri)
366 },
367 })
368}
369
370export function useProfileMuteMutationQueue(
371 profile: Shadow<bsky.profile.AnyProfileView>,
372) {
373 const queryClient = useQueryClient()
374 const did = profile.did
375 const initialMuted = profile.viewer?.muted
376 const muteMutation = useProfileMuteMutation()
377 const unmuteMutation = useProfileUnmuteMutation()
378
379 const queueToggle = useToggleMutationQueue({
380 initialState: initialMuted,
381 runMutation: async (_prevMuted, shouldMute) => {
382 if (shouldMute) {
383 await muteMutation.mutateAsync({
384 did,
385 })
386 return true
387 } else {
388 await unmuteMutation.mutateAsync({
389 did,
390 })
391 return false
392 }
393 },
394 onSuccess(finalMuted) {
395 // finalize
396 updateProfileShadow(queryClient, did, {muted: finalMuted})
397 },
398 })
399
400 const queueMute = useCallback(() => {
401 // optimistically update
402 updateProfileShadow(queryClient, did, {
403 muted: true,
404 })
405 return queueToggle(true)
406 }, [queryClient, did, queueToggle])
407
408 const queueUnmute = useCallback(() => {
409 // optimistically update
410 updateProfileShadow(queryClient, did, {
411 muted: false,
412 })
413 return queueToggle(false)
414 }, [queryClient, did, queueToggle])
415
416 return [queueMute, queueUnmute]
417}
418
419function useProfileMuteMutation() {
420 const queryClient = useQueryClient()
421 const agent = useAgent()
422 return useMutation<void, Error, {did: string}>({
423 mutationFn: async ({did}) => {
424 await agent.mute(did)
425 },
426 onSuccess() {
427 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
428 },
429 })
430}
431
432function useProfileUnmuteMutation() {
433 const queryClient = useQueryClient()
434 const agent = useAgent()
435 return useMutation<void, Error, {did: string}>({
436 mutationFn: async ({did}) => {
437 await agent.unmute(did)
438 },
439 onSuccess() {
440 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
441 },
442 })
443}
444
445export function useProfileBlockMutationQueue(
446 profile: Shadow<bsky.profile.AnyProfileView>,
447) {
448 const queryClient = useQueryClient()
449 const did = profile.did
450 const initialBlockingUri = profile.viewer?.blocking
451 const blockMutation = useProfileBlockMutation()
452 const unblockMutation = useProfileUnblockMutation()
453
454 const queueToggle = useToggleMutationQueue({
455 initialState: initialBlockingUri,
456 runMutation: async (prevBlockUri, shouldFollow) => {
457 if (shouldFollow) {
458 const {uri} = await blockMutation.mutateAsync({
459 did,
460 })
461 return uri
462 } else {
463 if (prevBlockUri) {
464 await unblockMutation.mutateAsync({
465 did,
466 blockUri: prevBlockUri,
467 })
468 }
469 return undefined
470 }
471 },
472 onSuccess(finalBlockingUri) {
473 // finalize
474 updateProfileShadow(queryClient, did, {
475 blockingUri: finalBlockingUri,
476 })
477 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
478 },
479 })
480
481 const queueBlock = useCallback(() => {
482 // optimistically update
483 updateProfileShadow(queryClient, did, {
484 blockingUri: 'pending',
485 })
486 return queueToggle(true)
487 }, [queryClient, did, queueToggle])
488
489 const queueUnblock = useCallback(() => {
490 // optimistically update
491 updateProfileShadow(queryClient, did, {
492 blockingUri: undefined,
493 })
494 return queueToggle(false)
495 }, [queryClient, did, queueToggle])
496
497 return [queueBlock, queueUnblock]
498}
499
500function useProfileBlockMutation() {
501 const {currentAccount} = useSession()
502 const agent = useAgent()
503 const queryClient = useQueryClient()
504 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
505 mutationFn: async ({did}) => {
506 if (!currentAccount) {
507 throw new Error('Not signed in')
508 }
509 return await agent.app.bsky.graph.block.create(
510 {repo: currentAccount.did},
511 {subject: did, createdAt: new Date().toISOString()},
512 )
513 },
514 onSuccess(_, {did}) {
515 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
516 resetProfilePostsQueries(queryClient, did, 1000)
517 },
518 })
519}
520
521function useProfileUnblockMutation() {
522 const {currentAccount} = useSession()
523 const agent = useAgent()
524 const queryClient = useQueryClient()
525 return useMutation<void, Error, {did: string; blockUri: string}>({
526 mutationFn: async ({blockUri}) => {
527 if (!currentAccount) {
528 throw new Error('Not signed in')
529 }
530 const {rkey} = new AtUri(blockUri)
531 await agent.app.bsky.graph.block.delete({
532 repo: currentAccount.did,
533 rkey,
534 })
535 },
536 onSuccess(_, {did}) {
537 resetProfilePostsQueries(queryClient, did, 1000)
538 },
539 })
540}
541
542async function whenAppViewReady(
543 agent: BskyAgent,
544 actor: string,
545 fn: (res: AppBskyActorGetProfile.Response) => boolean,
546) {
547 await until(
548 5, // 5 tries
549 1e3, // 1s delay between tries
550 fn,
551 () => agent.app.bsky.actor.getProfile({actor}),
552 )
553}
554
555export function* findAllProfilesInQueryData(
556 queryClient: QueryClient,
557 did: string,
558): Generator<AppBskyActorDefs.ProfileViewDetailed, void> {
559 const profileQueryDatas =
560 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({
561 queryKey: [RQKEY_ROOT],
562 })
563 for (const [_queryKey, queryData] of profileQueryDatas) {
564 if (!queryData) {
565 continue
566 }
567 if (queryData.did === did) {
568 yield queryData
569 }
570 }
571 const profilesQueryDatas =
572 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({
573 queryKey: [profilesQueryKeyRoot],
574 })
575 for (const [_queryKey, queryData] of profilesQueryDatas) {
576 if (!queryData) {
577 continue
578 }
579 for (let profile of queryData.profiles) {
580 if (profile.did === did) {
581 yield profile
582 }
583 }
584 }
585}
586
587export function findProfileQueryData(
588 queryClient: QueryClient,
589 did: string,
590): AppBskyActorDefs.ProfileViewDetailed | undefined {
591 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>(
592 RQKEY(did),
593 )
594}