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