An ATproto social media client -- with an independent Appview.
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) {
246 const agent = useAgent()
247 const queryClient = useQueryClient()
248 const did = profile.did
249 const initialFollowingUri = profile.viewer?.following
250 const followMutation = useProfileFollowMutation(logContext, profile)
251 const unfollowMutation = useProfileUnfollowMutation(logContext)
252
253 const queueToggle = useToggleMutationQueue({
254 initialState: initialFollowingUri,
255 runMutation: async (prevFollowingUri, shouldFollow) => {
256 if (shouldFollow) {
257 const {uri} = await followMutation.mutateAsync({
258 did,
259 })
260 userActionHistory.follow([did])
261 return uri
262 } else {
263 if (prevFollowingUri) {
264 await unfollowMutation.mutateAsync({
265 did,
266 followUri: prevFollowingUri,
267 })
268 userActionHistory.unfollow([did])
269 }
270 return undefined
271 }
272 },
273 onSuccess(finalFollowingUri) {
274 // finalize
275 updateProfileShadow(queryClient, did, {
276 followingUri: finalFollowingUri,
277 })
278
279 if (finalFollowingUri) {
280 agent.app.bsky.graph
281 .getSuggestedFollowsByActor({
282 actor: did,
283 })
284 .then(res => {
285 const dids = res.data.suggestions
286 .filter(a => !a.viewer?.following)
287 .map(a => a.did)
288 .slice(0, 8)
289 userActionHistory.followSuggestion(dids)
290 })
291 }
292 },
293 })
294
295 const queueFollow = useCallback(() => {
296 // optimistically update
297 updateProfileShadow(queryClient, did, {
298 followingUri: 'pending',
299 })
300 return queueToggle(true)
301 }, [queryClient, did, queueToggle])
302
303 const queueUnfollow = useCallback(() => {
304 // optimistically update
305 updateProfileShadow(queryClient, did, {
306 followingUri: undefined,
307 })
308 return queueToggle(false)
309 }, [queryClient, did, queueToggle])
310
311 return [queueFollow, queueUnfollow]
312}
313
314function useProfileFollowMutation(
315 logContext: LogEvents['profile:follow']['logContext'],
316 profile: Shadow<bsky.profile.AnyProfileView>,
317) {
318 const {currentAccount} = useSession()
319 const agent = useAgent()
320 const queryClient = useQueryClient()
321 const {captureAction} = useProgressGuideControls()
322
323 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
324 mutationFn: async ({did}) => {
325 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
326 if (currentAccount) {
327 ownProfile = findProfileQueryData(queryClient, currentAccount.did)
328 }
329 captureAction(ProgressGuideAction.Follow)
330 logEvent('profile:follow', {
331 logContext,
332 didBecomeMutual: profile.viewer
333 ? Boolean(profile.viewer.followedBy)
334 : undefined,
335 followeeClout:
336 'followersCount' in profile
337 ? toClout(profile.followersCount)
338 : undefined,
339 followerClout: toClout(ownProfile?.followersCount),
340 })
341 return await agent.follow(did)
342 },
343 })
344}
345
346function useProfileUnfollowMutation(
347 logContext: LogEvents['profile:unfollow']['logContext'],
348) {
349 const agent = useAgent()
350 return useMutation<void, Error, {did: string; followUri: string}>({
351 mutationFn: async ({followUri}) => {
352 logEvent('profile:unfollow', {logContext})
353 return await agent.deleteFollow(followUri)
354 },
355 })
356}
357
358export function useProfileMuteMutationQueue(
359 profile: Shadow<bsky.profile.AnyProfileView>,
360) {
361 const queryClient = useQueryClient()
362 const did = profile.did
363 const initialMuted = profile.viewer?.muted
364 const muteMutation = useProfileMuteMutation()
365 const unmuteMutation = useProfileUnmuteMutation()
366
367 const queueToggle = useToggleMutationQueue({
368 initialState: initialMuted,
369 runMutation: async (_prevMuted, shouldMute) => {
370 if (shouldMute) {
371 await muteMutation.mutateAsync({
372 did,
373 })
374 return true
375 } else {
376 await unmuteMutation.mutateAsync({
377 did,
378 })
379 return false
380 }
381 },
382 onSuccess(finalMuted) {
383 // finalize
384 updateProfileShadow(queryClient, did, {muted: finalMuted})
385 },
386 })
387
388 const queueMute = useCallback(() => {
389 // optimistically update
390 updateProfileShadow(queryClient, did, {
391 muted: true,
392 })
393 return queueToggle(true)
394 }, [queryClient, did, queueToggle])
395
396 const queueUnmute = useCallback(() => {
397 // optimistically update
398 updateProfileShadow(queryClient, did, {
399 muted: false,
400 })
401 return queueToggle(false)
402 }, [queryClient, did, queueToggle])
403
404 return [queueMute, queueUnmute]
405}
406
407function useProfileMuteMutation() {
408 const queryClient = useQueryClient()
409 const agent = useAgent()
410 return useMutation<void, Error, {did: string}>({
411 mutationFn: async ({did}) => {
412 await agent.mute(did)
413 },
414 onSuccess() {
415 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
416 },
417 })
418}
419
420function useProfileUnmuteMutation() {
421 const queryClient = useQueryClient()
422 const agent = useAgent()
423 return useMutation<void, Error, {did: string}>({
424 mutationFn: async ({did}) => {
425 await agent.unmute(did)
426 },
427 onSuccess() {
428 queryClient.invalidateQueries({queryKey: RQKEY_MY_MUTED()})
429 },
430 })
431}
432
433export function useProfileBlockMutationQueue(
434 profile: Shadow<bsky.profile.AnyProfileView>,
435) {
436 const queryClient = useQueryClient()
437 const did = profile.did
438 const initialBlockingUri = profile.viewer?.blocking
439 const blockMutation = useProfileBlockMutation()
440 const unblockMutation = useProfileUnblockMutation()
441
442 const queueToggle = useToggleMutationQueue({
443 initialState: initialBlockingUri,
444 runMutation: async (prevBlockUri, shouldFollow) => {
445 if (shouldFollow) {
446 const {uri} = await blockMutation.mutateAsync({
447 did,
448 })
449 return uri
450 } else {
451 if (prevBlockUri) {
452 await unblockMutation.mutateAsync({
453 did,
454 blockUri: prevBlockUri,
455 })
456 }
457 return undefined
458 }
459 },
460 onSuccess(finalBlockingUri) {
461 // finalize
462 updateProfileShadow(queryClient, did, {
463 blockingUri: finalBlockingUri,
464 })
465 queryClient.invalidateQueries({queryKey: [RQKEY_LIST_CONVOS]})
466 },
467 })
468
469 const queueBlock = useCallback(() => {
470 // optimistically update
471 updateProfileShadow(queryClient, did, {
472 blockingUri: 'pending',
473 })
474 return queueToggle(true)
475 }, [queryClient, did, queueToggle])
476
477 const queueUnblock = useCallback(() => {
478 // optimistically update
479 updateProfileShadow(queryClient, did, {
480 blockingUri: undefined,
481 })
482 return queueToggle(false)
483 }, [queryClient, did, queueToggle])
484
485 return [queueBlock, queueUnblock]
486}
487
488function useProfileBlockMutation() {
489 const {currentAccount} = useSession()
490 const agent = useAgent()
491 const queryClient = useQueryClient()
492 return useMutation<{uri: string; cid: string}, Error, {did: string}>({
493 mutationFn: async ({did}) => {
494 if (!currentAccount) {
495 throw new Error('Not signed in')
496 }
497 return await agent.app.bsky.graph.block.create(
498 {repo: currentAccount.did},
499 {subject: did, createdAt: new Date().toISOString()},
500 )
501 },
502 onSuccess(_, {did}) {
503 queryClient.invalidateQueries({queryKey: RQKEY_MY_BLOCKED()})
504 resetProfilePostsQueries(queryClient, did, 1000)
505 },
506 })
507}
508
509function useProfileUnblockMutation() {
510 const {currentAccount} = useSession()
511 const agent = useAgent()
512 const queryClient = useQueryClient()
513 return useMutation<void, Error, {did: string; blockUri: string}>({
514 mutationFn: async ({blockUri}) => {
515 if (!currentAccount) {
516 throw new Error('Not signed in')
517 }
518 const {rkey} = new AtUri(blockUri)
519 await agent.app.bsky.graph.block.delete({
520 repo: currentAccount.did,
521 rkey,
522 })
523 },
524 onSuccess(_, {did}) {
525 resetProfilePostsQueries(queryClient, did, 1000)
526 },
527 })
528}
529
530async function whenAppViewReady(
531 agent: BskyAgent,
532 actor: string,
533 fn: (res: AppBskyActorGetProfile.Response) => boolean,
534) {
535 await until(
536 5, // 5 tries
537 1e3, // 1s delay between tries
538 fn,
539 () => agent.app.bsky.actor.getProfile({actor}),
540 )
541}
542
543export function* findAllProfilesInQueryData(
544 queryClient: QueryClient,
545 did: string,
546): Generator<AppBskyActorDefs.ProfileViewDetailed, void> {
547 const profileQueryDatas =
548 queryClient.getQueriesData<AppBskyActorDefs.ProfileViewDetailed>({
549 queryKey: [RQKEY_ROOT],
550 })
551 for (const [_queryKey, queryData] of profileQueryDatas) {
552 if (!queryData) {
553 continue
554 }
555 if (queryData.did === did) {
556 yield queryData
557 }
558 }
559 const profilesQueryDatas =
560 queryClient.getQueriesData<AppBskyActorGetProfiles.OutputSchema>({
561 queryKey: [profilesQueryKeyRoot],
562 })
563 for (const [_queryKey, queryData] of profilesQueryDatas) {
564 if (!queryData) {
565 continue
566 }
567 for (let profile of queryData.profiles) {
568 if (profile.did === did) {
569 yield profile
570 }
571 }
572 }
573}
574
575export function findProfileQueryData(
576 queryClient: QueryClient,
577 did: string,
578): AppBskyActorDefs.ProfileViewDetailed | undefined {
579 return queryClient.getQueryData<AppBskyActorDefs.ProfileViewDetailed>(
580 RQKEY(did),
581 )
582}