forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {useCallback} from 'react'
2import {type AppBskyActorDefs, type AppBskyFeedDefs, AtUri} from '@atproto/api'
3import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
4
5import {useToggleMutationQueue} from '#/lib/hooks/useToggleMutationQueue'
6import {type LogEvents, toClout} from '#/lib/statsig/statsig'
7import {logger} from '#/logger'
8import {updatePostShadow} from '#/state/cache/post-shadow'
9import {type Shadow} from '#/state/cache/types'
10import {useDisableViaRepostNotification} from '#/state/preferences/disable-via-repost-notification'
11import {useAgent, useSession} from '#/state/session'
12import * as userActionHistory from '#/state/userActionHistory'
13import {useIsThreadMuted, useSetThreadMute} from '../cache/thread-mutes'
14import {findProfileQueryData} from './profile'
15
16const RQKEY_ROOT = 'post'
17export const RQKEY = (postUri: string) => [RQKEY_ROOT, postUri]
18
19export function usePostQuery(uri: string | undefined) {
20 const agent = useAgent()
21 return useQuery<AppBskyFeedDefs.PostView>({
22 queryKey: RQKEY(uri || ''),
23 async queryFn() {
24 const urip = new AtUri(uri!)
25
26 if (!urip.host.startsWith('did:')) {
27 const res = await agent.resolveHandle({
28 handle: urip.host,
29 })
30 // @ts-expect-error TODO new-sdk-migration
31 urip.host = res.data.did
32 }
33
34 const res = await agent.getPosts({uris: [urip.toString()]})
35 if (res.success && res.data.posts[0]) {
36 return res.data.posts[0]
37 }
38
39 throw new Error('No data')
40 },
41 enabled: !!uri,
42 })
43}
44
45export function useGetPost() {
46 const queryClient = useQueryClient()
47 const agent = useAgent()
48 return useCallback(
49 async ({uri}: {uri: string}) => {
50 return queryClient.fetchQuery({
51 queryKey: RQKEY(uri || ''),
52 async queryFn() {
53 const urip = new AtUri(uri)
54
55 if (!urip.host.startsWith('did:')) {
56 const res = await agent.resolveHandle({
57 handle: urip.host,
58 })
59 // @ts-expect-error TODO new-sdk-migration
60 urip.host = res.data.did
61 }
62
63 const res = await agent.getPosts({
64 uris: [urip.toString()],
65 })
66
67 if (res.success && res.data.posts[0]) {
68 return res.data.posts[0]
69 }
70
71 throw new Error('useGetPost: post not found')
72 },
73 })
74 },
75 [queryClient, agent],
76 )
77}
78
79export function useGetPosts() {
80 const queryClient = useQueryClient()
81 const agent = useAgent()
82 return useCallback(
83 async ({uris}: {uris: string[]}) => {
84 return queryClient.fetchQuery({
85 queryKey: RQKEY(uris.join(',') || ''),
86 async queryFn() {
87 const res = await agent.getPosts({
88 uris,
89 })
90
91 if (res.success) {
92 return res.data.posts
93 } else {
94 throw new Error('useGetPosts failed')
95 }
96 },
97 })
98 },
99 [queryClient, agent],
100 )
101}
102
103export function usePostLikeMutationQueue(
104 post: Shadow<AppBskyFeedDefs.PostView>,
105 viaRepost: {uri: string; cid: string} | undefined,
106 feedDescriptor: string | undefined,
107 logContext: LogEvents['post:like']['logContext'] &
108 LogEvents['post:unlike']['logContext'],
109) {
110 const queryClient = useQueryClient()
111 const postUri = post.uri
112 const postCid = post.cid
113 const initialLikeUri = post.viewer?.like
114 const likeMutation = usePostLikeMutation(feedDescriptor, logContext, post)
115 const unlikeMutation = usePostUnlikeMutation(feedDescriptor, logContext)
116 const disableViaRepostNotification = useDisableViaRepostNotification()
117
118 const queueToggle = useToggleMutationQueue({
119 initialState: initialLikeUri,
120 runMutation: async (prevLikeUri, shouldLike) => {
121 if (shouldLike) {
122 const {uri: likeUri} = await likeMutation.mutateAsync({
123 uri: postUri,
124 cid: postCid,
125 via: disableViaRepostNotification ? undefined : viaRepost,
126 })
127 userActionHistory.like([postUri])
128 return likeUri
129 } else {
130 if (prevLikeUri) {
131 await unlikeMutation.mutateAsync({
132 postUri: postUri,
133 likeUri: prevLikeUri,
134 })
135 userActionHistory.unlike([postUri])
136 }
137 return undefined
138 }
139 },
140 onSuccess(finalLikeUri) {
141 // finalize
142 updatePostShadow(queryClient, postUri, {
143 likeUri: finalLikeUri,
144 })
145 },
146 })
147
148 const queueLike = useCallback(() => {
149 // optimistically update
150 updatePostShadow(queryClient, postUri, {
151 likeUri: 'pending',
152 })
153 return queueToggle(true)
154 }, [queryClient, postUri, queueToggle])
155
156 const queueUnlike = useCallback(() => {
157 // optimistically update
158 updatePostShadow(queryClient, postUri, {
159 likeUri: undefined,
160 })
161 return queueToggle(false)
162 }, [queryClient, postUri, queueToggle])
163
164 return [queueLike, queueUnlike]
165}
166
167function usePostLikeMutation(
168 feedDescriptor: string | undefined,
169 logContext: LogEvents['post:like']['logContext'],
170 post: Shadow<AppBskyFeedDefs.PostView>,
171) {
172 const {currentAccount} = useSession()
173 const queryClient = useQueryClient()
174 const postAuthor = post.author
175 const agent = useAgent()
176 return useMutation<
177 {uri: string}, // responds with the uri of the like
178 Error,
179 {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
180 >({
181 mutationFn: ({uri, cid, via}) => {
182 let ownProfile: AppBskyActorDefs.ProfileViewDetailed | undefined
183 if (currentAccount) {
184 ownProfile = findProfileQueryData(queryClient, currentAccount.did)
185 }
186 logger.metric('post:like', {
187 logContext,
188 doesPosterFollowLiker: postAuthor.viewer
189 ? Boolean(postAuthor.viewer.followedBy)
190 : undefined,
191 doesLikerFollowPoster: postAuthor.viewer
192 ? Boolean(postAuthor.viewer.following)
193 : undefined,
194 likerClout: toClout(ownProfile?.followersCount),
195 postClout:
196 post.likeCount != null &&
197 post.repostCount != null &&
198 post.replyCount != null
199 ? toClout(post.likeCount + post.repostCount + post.replyCount)
200 : undefined,
201 feedDescriptor: feedDescriptor,
202 })
203 return agent.like(uri, cid, via)
204 },
205 })
206}
207
208function usePostUnlikeMutation(
209 feedDescriptor: string | undefined,
210 logContext: LogEvents['post:unlike']['logContext'],
211) {
212 const agent = useAgent()
213 return useMutation<void, Error, {postUri: string; likeUri: string}>({
214 mutationFn: ({likeUri}) => {
215 logger.metric('post:unlike', {logContext, feedDescriptor})
216 return agent.deleteLike(likeUri)
217 },
218 })
219}
220
221export function usePostRepostMutationQueue(
222 post: Shadow<AppBskyFeedDefs.PostView>,
223 viaRepost: {uri: string; cid: string} | undefined,
224 feedDescriptor: string | undefined,
225 logContext: LogEvents['post:repost']['logContext'] &
226 LogEvents['post:unrepost']['logContext'],
227) {
228 const queryClient = useQueryClient()
229 const postUri = post.uri
230 const postCid = post.cid
231 const initialRepostUri = post.viewer?.repost
232 const repostMutation = usePostRepostMutation(feedDescriptor, logContext)
233 const unrepostMutation = usePostUnrepostMutation(feedDescriptor, logContext)
234 const disableViaRepostNotification = useDisableViaRepostNotification()
235
236 const queueToggle = useToggleMutationQueue({
237 initialState: initialRepostUri,
238 runMutation: async (prevRepostUri, shouldRepost) => {
239 if (shouldRepost) {
240 const {uri: repostUri} = await repostMutation.mutateAsync({
241 uri: postUri,
242 cid: postCid,
243 via: disableViaRepostNotification ? undefined : viaRepost,
244 })
245 return repostUri
246 } else {
247 if (prevRepostUri) {
248 await unrepostMutation.mutateAsync({
249 postUri: postUri,
250 repostUri: prevRepostUri,
251 })
252 }
253 return undefined
254 }
255 },
256 onSuccess(finalRepostUri) {
257 // finalize
258 updatePostShadow(queryClient, postUri, {
259 repostUri: finalRepostUri,
260 })
261 },
262 })
263
264 const queueRepost = useCallback(() => {
265 // optimistically update
266 updatePostShadow(queryClient, postUri, {
267 repostUri: 'pending',
268 })
269 return queueToggle(true)
270 }, [queryClient, postUri, queueToggle])
271
272 const queueUnrepost = useCallback(() => {
273 // optimistically update
274 updatePostShadow(queryClient, postUri, {
275 repostUri: undefined,
276 })
277 return queueToggle(false)
278 }, [queryClient, postUri, queueToggle])
279
280 return [queueRepost, queueUnrepost]
281}
282
283function usePostRepostMutation(
284 feedDescriptor: string | undefined,
285 logContext: LogEvents['post:repost']['logContext'],
286) {
287 const agent = useAgent()
288 return useMutation<
289 {uri: string}, // responds with the uri of the repost
290 Error,
291 {uri: string; cid: string; via?: {uri: string; cid: string}} // the post's uri and cid, and the repost uri/cid if present
292 >({
293 mutationFn: ({uri, cid, via}) => {
294 logger.metric('post:repost', {logContext, feedDescriptor})
295 return agent.repost(uri, cid, via)
296 },
297 })
298}
299
300function usePostUnrepostMutation(
301 feedDescriptor: string | undefined,
302 logContext: LogEvents['post:unrepost']['logContext'],
303) {
304 const agent = useAgent()
305 return useMutation<void, Error, {postUri: string; repostUri: string}>({
306 mutationFn: ({repostUri}) => {
307 logger.metric('post:unrepost', {logContext, feedDescriptor})
308 return agent.deleteRepost(repostUri)
309 },
310 })
311}
312
313export function usePostDeleteMutation() {
314 const queryClient = useQueryClient()
315 const agent = useAgent()
316 return useMutation<void, Error, {uri: string}>({
317 mutationFn: async ({uri}) => {
318 await agent.deletePost(uri)
319 },
320 onSuccess(_, variables) {
321 updatePostShadow(queryClient, variables.uri, {isDeleted: true})
322 },
323 })
324}
325
326export function useThreadMuteMutationQueue(
327 post: Shadow<AppBskyFeedDefs.PostView>,
328 rootUri: string,
329) {
330 const threadMuteMutation = useThreadMuteMutation()
331 const threadUnmuteMutation = useThreadUnmuteMutation()
332 const isThreadMuted = useIsThreadMuted(rootUri, post.viewer?.threadMuted)
333 const setThreadMute = useSetThreadMute()
334
335 const queueToggle = useToggleMutationQueue<boolean>({
336 initialState: isThreadMuted,
337 runMutation: async (_prev, shouldMute) => {
338 if (shouldMute) {
339 await threadMuteMutation.mutateAsync({
340 uri: rootUri,
341 })
342 return true
343 } else {
344 await threadUnmuteMutation.mutateAsync({
345 uri: rootUri,
346 })
347 return false
348 }
349 },
350 onSuccess(finalIsMuted) {
351 // finalize
352 setThreadMute(rootUri, finalIsMuted)
353 },
354 })
355
356 const queueMuteThread = useCallback(() => {
357 // optimistically update
358 setThreadMute(rootUri, true)
359 return queueToggle(true)
360 }, [setThreadMute, rootUri, queueToggle])
361
362 const queueUnmuteThread = useCallback(() => {
363 // optimistically update
364 setThreadMute(rootUri, false)
365 return queueToggle(false)
366 }, [rootUri, setThreadMute, queueToggle])
367
368 return [isThreadMuted, queueMuteThread, queueUnmuteThread] as const
369}
370
371function useThreadMuteMutation() {
372 const agent = useAgent()
373 return useMutation<
374 {},
375 Error,
376 {uri: string} // the root post's uri
377 >({
378 mutationFn: ({uri}) => {
379 logger.metric('post:mute', {})
380 return agent.api.app.bsky.graph.muteThread({root: uri})
381 },
382 })
383}
384
385function useThreadUnmuteMutation() {
386 const agent = useAgent()
387 return useMutation<{}, Error, {uri: string}>({
388 mutationFn: ({uri}) => {
389 logger.metric('post:unmute', {})
390 return agent.api.app.bsky.graph.unmuteThread({root: uri})
391 },
392 })
393}