👁️
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}