👁️
at dev 387 lines 9.4 kB view raw
1/** 2 * TanStack Query integration for comment and reply records. 3 * - Query options for fetching record content (PDS) 4 * - Mutations with optimistic updates 5 * 6 * Backlink queries (who commented, counts) are in constellation-queries.ts 7 */ 8 9import type { Did } from "@atcute/lexicons"; 10import { now as createTid } from "@atcute/tid"; 11import { queryOptions, useQueryClient } from "@tanstack/react-query"; 12import { toast } from "sonner"; 13import { 14 type AtUri, 15 asRkey, 16 computeRecordCid, 17 createCommentRecord, 18 createReplyRecord, 19 deleteCommentRecord, 20 deleteReplyRecord, 21 getCommentRecord, 22 getReplyRecord, 23 type Rkey, 24 updateCommentRecord, 25 updateReplyRecord, 26} from "./atproto-client"; 27import { COMMENT_NSID, REPLY_NSID } from "./constellation-client"; 28import type { 29 ComDeckbelcherSocialComment, 30 ComDeckbelcherSocialReply, 31} from "./lexicons/index"; 32import { 33 optimisticBacklinks, 34 optimisticCount, 35 optimisticRecord, 36 runOptimistic, 37} from "./optimistic-utils"; 38import type { SocialItemUri } from "./social-item-types"; 39import { useAuth } from "./useAuth"; 40import { useMutationWithToast } from "./useMutationWithToast"; 41 42type CommentRecord = ComDeckbelcherSocialComment.Main; 43type ReplyRecord = ComDeckbelcherSocialReply.Main; 44 45// ============================================================================ 46// Query Options (fetch record content from PDS) 47// ============================================================================ 48 49export interface CommentRecordData { 50 comment: CommentRecord; 51 cid: string; 52} 53 54export const getCommentQueryOptions = (did: Did, rkey: Rkey) => 55 queryOptions({ 56 queryKey: ["comment", did, rkey] as const, 57 queryFn: async (): Promise<CommentRecordData> => { 58 const result = await getCommentRecord(did, rkey); 59 if (!result.success) { 60 throw result.error; 61 } 62 return { 63 comment: result.data.value, 64 cid: result.data.cid, 65 }; 66 }, 67 staleTime: 60 * 1000, 68 }); 69 70export interface ReplyRecordData { 71 reply: ReplyRecord; 72 cid: string; 73} 74 75export const getReplyQueryOptions = (did: Did, rkey: Rkey) => 76 queryOptions({ 77 queryKey: ["reply", did, rkey] as const, 78 queryFn: async (): Promise<ReplyRecordData> => { 79 const result = await getReplyRecord(did, rkey); 80 if (!result.success) { 81 throw result.error; 82 } 83 return { 84 reply: result.data.value, 85 cid: result.data.cid, 86 }; 87 }, 88 staleTime: 60 * 1000, 89 }); 90 91// ============================================================================ 92// Mutations 93// ============================================================================ 94 95function getSubjectUri(subject: CommentRecord["subject"]): string | undefined { 96 if ("ref" in subject) { 97 const ref = subject.ref; 98 if ("oracleUri" in ref) return ref.oracleUri; 99 if ("uri" in ref) return ref.uri; 100 } 101 return undefined; 102} 103 104interface CreateCommentParams { 105 record: CommentRecord; 106 rkey: Rkey; 107} 108 109export function useCreateCommentMutation() { 110 const queryClient = useQueryClient(); 111 const { agent, session } = useAuth(); 112 113 return useMutationWithToast({ 114 mutationFn: async ({ record, rkey }: CreateCommentParams) => { 115 if (!agent) throw new Error("Not authenticated"); 116 const result = await createCommentRecord(agent, record, rkey); 117 if (!result.success) throw result.error; 118 return result.data; 119 }, 120 onMutate: async ({ record, rkey }) => { 121 const subjectUri = getSubjectUri(record.subject); 122 const userDid = session?.info.sub; 123 if (!subjectUri || !userDid) return; 124 125 const cid = await computeRecordCid(record); 126 127 const rollback = await runOptimistic([ 128 optimisticCount( 129 queryClient, 130 ["constellation", "commentCount", subjectUri], 131 1, 132 ), 133 optimisticBacklinks( 134 queryClient, 135 ["constellation", "comments", subjectUri], 136 "add", 137 { 138 did: userDid, 139 collection: COMMENT_NSID, 140 rkey, 141 }, 142 ), 143 optimisticRecord<CommentRecordData>( 144 queryClient, 145 ["comment", userDid, rkey], 146 { comment: record, cid }, 147 ), 148 ]); 149 150 return { rollback }; 151 }, 152 onError: (_err, _params, context) => { 153 context?.rollback(); 154 }, 155 onSuccess: () => { 156 toast.success("Comment posted"); 157 }, 158 }); 159} 160 161/** Generate a TID for use as an rkey */ 162export function generateRkey(): Rkey { 163 return asRkey(createTid()); 164} 165 166interface CreateReplyParams { 167 record: ReplyRecord; 168 rkey: Rkey; 169} 170 171export function useCreateReplyMutation() { 172 const queryClient = useQueryClient(); 173 const { agent, session } = useAuth(); 174 175 return useMutationWithToast({ 176 mutationFn: async ({ record, rkey }: CreateReplyParams) => { 177 if (!agent) throw new Error("Not authenticated"); 178 const result = await createReplyRecord(agent, record, rkey); 179 if (!result.success) throw result.error; 180 return result.data; 181 }, 182 onMutate: async ({ record, rkey }) => { 183 const userDid = session?.info.sub; 184 if (!userDid) return; 185 186 const parentUri = record.parent.uri; 187 const cid = await computeRecordCid(record); 188 189 const rollback = await runOptimistic([ 190 optimisticCount( 191 queryClient, 192 ["constellation", "directReplyCount", parentUri], 193 1, 194 ), 195 optimisticBacklinks( 196 queryClient, 197 ["constellation", "directReplies", parentUri], 198 "add", 199 { 200 did: userDid, 201 collection: REPLY_NSID, 202 rkey, 203 }, 204 ), 205 optimisticRecord<ReplyRecordData>( 206 queryClient, 207 ["reply", userDid, rkey], 208 { reply: record, cid }, 209 ), 210 ]); 211 212 return { rollback }; 213 }, 214 onError: (_err, _params, context) => { 215 context?.rollback(); 216 }, 217 onSuccess: () => { 218 toast.success("Reply posted"); 219 }, 220 }); 221} 222 223interface DeleteCommentParams { 224 rkey: Rkey; 225 subjectUri: SocialItemUri; 226 did: Did; 227} 228 229export function useDeleteCommentMutation() { 230 const queryClient = useQueryClient(); 231 const { agent } = useAuth(); 232 233 return useMutationWithToast({ 234 mutationFn: async ({ rkey }: DeleteCommentParams) => { 235 if (!agent) throw new Error("Not authenticated"); 236 const result = await deleteCommentRecord(agent, rkey); 237 if (!result.success) throw result.error; 238 return result.data; 239 }, 240 onMutate: async ({ rkey, subjectUri, did }) => { 241 const rollback = await runOptimistic([ 242 optimisticCount( 243 queryClient, 244 ["constellation", "commentCount", subjectUri], 245 -1, 246 ), 247 optimisticBacklinks( 248 queryClient, 249 ["constellation", "comments", subjectUri], 250 "remove", 251 { 252 did, 253 collection: COMMENT_NSID, 254 rkey, 255 }, 256 ), 257 ]); 258 259 return { rollback }; 260 }, 261 onError: (_err, _vars, context) => { 262 context?.rollback(); 263 }, 264 onSuccess: () => { 265 toast.success("Comment deleted"); 266 }, 267 }); 268} 269 270interface DeleteReplyParams { 271 rkey: Rkey; 272 parentUri: AtUri; 273 did: Did; 274} 275 276export function useDeleteReplyMutation() { 277 const queryClient = useQueryClient(); 278 const { agent } = useAuth(); 279 280 return useMutationWithToast({ 281 mutationFn: async ({ rkey }: DeleteReplyParams) => { 282 if (!agent) throw new Error("Not authenticated"); 283 const result = await deleteReplyRecord(agent, rkey); 284 if (!result.success) throw result.error; 285 return result.data; 286 }, 287 onMutate: async ({ rkey, parentUri, did }) => { 288 const rollback = await runOptimistic([ 289 optimisticCount( 290 queryClient, 291 ["constellation", "directReplyCount", parentUri], 292 -1, 293 ), 294 optimisticBacklinks( 295 queryClient, 296 ["constellation", "directReplies", parentUri], 297 "remove", 298 { 299 did, 300 collection: REPLY_NSID, 301 rkey, 302 }, 303 ), 304 ]); 305 306 return { rollback }; 307 }, 308 onError: (_err, _vars, context) => { 309 context?.rollback(); 310 }, 311 onSuccess: () => { 312 toast.success("Reply deleted"); 313 }, 314 }); 315} 316 317interface UpdateCommentParams { 318 did: Did; 319 rkey: Rkey; 320 record: CommentRecord; 321} 322 323export function useUpdateCommentMutation() { 324 const queryClient = useQueryClient(); 325 const { agent } = useAuth(); 326 327 return useMutationWithToast({ 328 mutationFn: async ({ rkey, record }: UpdateCommentParams) => { 329 if (!agent) throw new Error("Not authenticated"); 330 const result = await updateCommentRecord(agent, rkey, record); 331 if (!result.success) throw result.error; 332 return result.data; 333 }, 334 onMutate: async ({ did, rkey, record }) => { 335 const rollback = await runOptimistic([ 336 optimisticRecord<CommentRecordData>( 337 queryClient, 338 ["comment", did, rkey], 339 (old) => (old ? { ...old, comment: record } : undefined), 340 ), 341 ]); 342 return { rollback }; 343 }, 344 onError: (_err, _vars, context) => { 345 context?.rollback(); 346 }, 347 onSuccess: () => { 348 toast.success("Comment updated"); 349 }, 350 }); 351} 352 353interface UpdateReplyParams { 354 did: Did; 355 rkey: Rkey; 356 record: ReplyRecord; 357} 358 359export function useUpdateReplyMutation() { 360 const queryClient = useQueryClient(); 361 const { agent } = useAuth(); 362 363 return useMutationWithToast({ 364 mutationFn: async ({ rkey, record }: UpdateReplyParams) => { 365 if (!agent) throw new Error("Not authenticated"); 366 const result = await updateReplyRecord(agent, rkey, record); 367 if (!result.success) throw result.error; 368 return result.data; 369 }, 370 onMutate: async ({ did, rkey, record }) => { 371 const rollback = await runOptimistic([ 372 optimisticRecord<ReplyRecordData>( 373 queryClient, 374 ["reply", did, rkey], 375 (old) => (old ? { ...old, reply: record } : undefined), 376 ), 377 ]); 378 return { rollback }; 379 }, 380 onError: (_err, _vars, context) => { 381 context?.rollback(); 382 }, 383 onSuccess: () => { 384 toast.success("Reply updated"); 385 }, 386 }); 387}