Bluesky app fork with some witchin' additions 馃挮
at main 419 lines 12 kB view raw
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}