@hamzasaleemorg/convex-comments 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/LICENSE +201 -0
  3. package/README.md +581 -0
  4. package/dist/client/_generated/_ignore.d.ts +1 -0
  5. package/dist/client/_generated/_ignore.d.ts.map +1 -0
  6. package/dist/client/_generated/_ignore.js +3 -0
  7. package/dist/client/_generated/_ignore.js.map +1 -0
  8. package/dist/client/index.d.ts +745 -0
  9. package/dist/client/index.d.ts.map +1 -0
  10. package/dist/client/index.js +579 -0
  11. package/dist/client/index.js.map +1 -0
  12. package/dist/component/_generated/api.d.ts +44 -0
  13. package/dist/component/_generated/api.d.ts.map +1 -0
  14. package/dist/component/_generated/api.js +31 -0
  15. package/dist/component/_generated/api.js.map +1 -0
  16. package/dist/component/_generated/component.d.ts +673 -0
  17. package/dist/component/_generated/component.d.ts.map +1 -0
  18. package/dist/component/_generated/component.js +11 -0
  19. package/dist/component/_generated/component.js.map +1 -0
  20. package/dist/component/_generated/dataModel.d.ts +46 -0
  21. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  22. package/dist/component/_generated/dataModel.js +11 -0
  23. package/dist/component/_generated/dataModel.js.map +1 -0
  24. package/dist/component/_generated/server.d.ts +121 -0
  25. package/dist/component/_generated/server.d.ts.map +1 -0
  26. package/dist/component/_generated/server.js +78 -0
  27. package/dist/component/_generated/server.js.map +1 -0
  28. package/dist/component/convex.config.d.ts +3 -0
  29. package/dist/component/convex.config.d.ts.map +1 -0
  30. package/dist/component/convex.config.js +3 -0
  31. package/dist/component/convex.config.js.map +1 -0
  32. package/dist/component/lib.d.ts +17 -0
  33. package/dist/component/lib.d.ts.map +1 -0
  34. package/dist/component/lib.js +18 -0
  35. package/dist/component/lib.js.map +1 -0
  36. package/dist/component/messages.d.ts +173 -0
  37. package/dist/component/messages.d.ts.map +1 -0
  38. package/dist/component/messages.js +410 -0
  39. package/dist/component/messages.js.map +1 -0
  40. package/dist/component/reactions.d.ts +51 -0
  41. package/dist/component/reactions.d.ts.map +1 -0
  42. package/dist/component/reactions.js +191 -0
  43. package/dist/component/reactions.js.map +1 -0
  44. package/dist/component/schema.d.ts +274 -0
  45. package/dist/component/schema.d.ts.map +1 -0
  46. package/dist/component/schema.js +159 -0
  47. package/dist/component/schema.js.map +1 -0
  48. package/dist/component/threads.d.ts +110 -0
  49. package/dist/component/threads.d.ts.map +1 -0
  50. package/dist/component/threads.js +276 -0
  51. package/dist/component/threads.js.map +1 -0
  52. package/dist/component/typing.d.ts +31 -0
  53. package/dist/component/typing.d.ts.map +1 -0
  54. package/dist/component/typing.js +147 -0
  55. package/dist/component/typing.js.map +1 -0
  56. package/dist/component/zones.d.ts +63 -0
  57. package/dist/component/zones.d.ts.map +1 -0
  58. package/dist/component/zones.js +159 -0
  59. package/dist/component/zones.js.map +1 -0
  60. package/dist/react/AddComment.d.ts +57 -0
  61. package/dist/react/AddComment.d.ts.map +1 -0
  62. package/dist/react/AddComment.js +285 -0
  63. package/dist/react/AddComment.js.map +1 -0
  64. package/dist/react/Comment.d.ts +70 -0
  65. package/dist/react/Comment.d.ts.map +1 -0
  66. package/dist/react/Comment.js +259 -0
  67. package/dist/react/Comment.js.map +1 -0
  68. package/dist/react/Comments.d.ts +74 -0
  69. package/dist/react/Comments.d.ts.map +1 -0
  70. package/dist/react/Comments.js +108 -0
  71. package/dist/react/Comments.js.map +1 -0
  72. package/dist/react/CommentsProvider.d.ts +104 -0
  73. package/dist/react/CommentsProvider.d.ts.map +1 -0
  74. package/dist/react/CommentsProvider.js +98 -0
  75. package/dist/react/CommentsProvider.js.map +1 -0
  76. package/dist/react/ReactionPicker.d.ts +28 -0
  77. package/dist/react/ReactionPicker.d.ts.map +1 -0
  78. package/dist/react/ReactionPicker.js +56 -0
  79. package/dist/react/ReactionPicker.js.map +1 -0
  80. package/dist/react/Thread.d.ts +84 -0
  81. package/dist/react/Thread.d.ts.map +1 -0
  82. package/dist/react/Thread.js +124 -0
  83. package/dist/react/Thread.js.map +1 -0
  84. package/dist/react/TypingIndicator.d.ts +25 -0
  85. package/dist/react/TypingIndicator.d.ts.map +1 -0
  86. package/dist/react/TypingIndicator.js +99 -0
  87. package/dist/react/TypingIndicator.js.map +1 -0
  88. package/dist/react/index.d.ts +15 -0
  89. package/dist/react/index.d.ts.map +1 -0
  90. package/dist/react/index.js +15 -0
  91. package/dist/react/index.js.map +1 -0
  92. package/package.json +106 -0
  93. package/src/client/_generated/_ignore.ts +1 -0
  94. package/src/client/index.ts +813 -0
  95. package/src/component/_generated/api.ts +60 -0
  96. package/src/component/_generated/component.ts +784 -0
  97. package/src/component/_generated/dataModel.ts +60 -0
  98. package/src/component/_generated/server.ts +156 -0
  99. package/src/component/convex.config.ts +3 -0
  100. package/src/component/lib.ts +57 -0
  101. package/src/component/messages.ts +476 -0
  102. package/src/component/reactions.ts +222 -0
  103. package/src/component/schema.ts +169 -0
  104. package/src/component/threads.ts +319 -0
  105. package/src/component/typing.ts +168 -0
  106. package/src/component/zones.ts +180 -0
  107. package/src/react/AddComment.tsx +463 -0
  108. package/src/react/Comment.tsx +519 -0
  109. package/src/react/Comments.tsx +276 -0
  110. package/src/react/CommentsProvider.tsx +197 -0
  111. package/src/react/ReactionPicker.tsx +95 -0
  112. package/src/react/Thread.tsx +336 -0
  113. package/src/react/TypingIndicator.tsx +144 -0
  114. package/src/react/index.ts +45 -0
@@ -0,0 +1,813 @@
1
+ /**
2
+ * Comments Component - Client Library
3
+ *
4
+ * This module provides a class-based client for interacting with the
5
+ * comments component, plus helper functions for re-exporting APIs.
6
+ */
7
+
8
+ import {
9
+ httpActionGeneric,
10
+ mutationGeneric,
11
+ queryGeneric,
12
+ } from "convex/server";
13
+ import type {
14
+ Auth,
15
+
16
+ GenericDataModel,
17
+ GenericMutationCtx,
18
+ GenericQueryCtx,
19
+ HttpRouter,
20
+ } from "convex/server";
21
+ import { v } from "convex/values";
22
+ import type { ComponentApi } from "../component/_generated/component.js";
23
+
24
+ // ============================================================================
25
+ // Types
26
+ // ============================================================================
27
+
28
+ /** Minimal context types for flexibility */
29
+ type QueryCtx = Pick<GenericQueryCtx<GenericDataModel>, "runQuery">;
30
+ type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runQuery" | "runMutation">;
31
+
32
+
33
+ /** Authentication operation types */
34
+ type AuthOperation =
35
+ | { type: "read"; zoneId?: string; threadId?: string }
36
+ | { type: "create"; zoneId?: string; threadId?: string }
37
+ | { type: "update"; messageId?: string; threadId?: string }
38
+ | { type: "delete"; messageId?: string; threadId?: string }
39
+ | { type: "react"; messageId?: string };
40
+
41
+ /** Authentication callback that returns the user ID or throws */
42
+ type AuthCallback = (
43
+ ctx: { auth: Auth },
44
+ operation: AuthOperation
45
+ ) => Promise<string>;
46
+
47
+ /** Attachment metadata */
48
+ interface Attachment {
49
+ type: "url" | "file" | "image";
50
+ url: string;
51
+ name?: string;
52
+ mimeType?: string;
53
+ size?: number;
54
+ }
55
+
56
+ /** Mention callbacks */
57
+ interface MentionCallbacks {
58
+ /** Called when a new message is created */
59
+ onNewMessage?: (params: {
60
+ messageId: string;
61
+ threadId: string;
62
+ authorId: string;
63
+ body: string;
64
+ mentions: Array<{ userId: string; start: number; end: number }>;
65
+ }) => Promise<void>;
66
+
67
+ /** Called for each mention in a message */
68
+ onMention?: (params: {
69
+ messageId: string;
70
+ mentionedUserId: string;
71
+ authorId: string;
72
+ body: string;
73
+ }) => Promise<void>;
74
+ }
75
+
76
+ // ============================================================================
77
+ // Comments Client Class
78
+ // ============================================================================
79
+
80
+ /**
81
+ * Class-based client for the Comments component.
82
+ *
83
+ * Usage:
84
+ * ```ts
85
+ * import { Comments } from "@your-org/comments";
86
+ * import { components } from "./_generated/api";
87
+ *
88
+ * const comments = new Comments(components.comments);
89
+ *
90
+ * // In a mutation/query handler:
91
+ * const zoneId = await comments.getOrCreateZone(ctx, { entityId: "doc_123" });
92
+ * const threads = await comments.getThreads(ctx, { zoneId });
93
+ * ```
94
+ */
95
+ export class Comments {
96
+ constructor(
97
+ public readonly component: ComponentApi,
98
+ private readonly callbacks?: MentionCallbacks
99
+ ) { }
100
+
101
+ // ==========================================================================
102
+ // Zone Methods
103
+ // ==========================================================================
104
+
105
+ /**
106
+ * Get or create a zone for an entity.
107
+ */
108
+ async getOrCreateZone(
109
+ ctx: MutationCtx,
110
+ args: { entityId: string; metadata?: unknown }
111
+ ): Promise<string> {
112
+ return await ctx.runMutation(this.component.lib.getOrCreateZone, args);
113
+ }
114
+
115
+ /**
116
+ * Get a zone by entity ID (without creating).
117
+ */
118
+ async getZone(ctx: QueryCtx, args: { entityId: string }) {
119
+ return await ctx.runQuery(this.component.lib.getZone, args);
120
+ }
121
+
122
+ /**
123
+ * Get a zone by its ID.
124
+ */
125
+ async getZoneById(ctx: QueryCtx, args: { zoneId: string }) {
126
+ return await ctx.runQuery(this.component.lib.getZoneById, { zoneId: args.zoneId });
127
+ }
128
+
129
+ /**
130
+ * Delete a zone and all its contents.
131
+ */
132
+ async deleteZone(ctx: MutationCtx, args: { zoneId: string }) {
133
+ return await ctx.runMutation(this.component.lib.deleteZone, { zoneId: args.zoneId });
134
+ }
135
+
136
+ // ==========================================================================
137
+ // Thread Methods
138
+ // ==========================================================================
139
+
140
+ /**
141
+ * Create a new thread in a zone.
142
+ */
143
+ async addThread(
144
+ ctx: MutationCtx,
145
+ args: {
146
+ zoneId: string;
147
+ position?: { x: number; y: number; anchor?: string };
148
+ metadata?: unknown;
149
+ }
150
+ ): Promise<string> {
151
+ return await ctx.runMutation(this.component.lib.addThread, {
152
+ zoneId: args.zoneId,
153
+ position: args.position,
154
+ metadata: args.metadata,
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Get a thread by ID.
160
+ */
161
+ async getThread(ctx: QueryCtx, args: { threadId: string }) {
162
+ return await ctx.runQuery(this.component.lib.getThread, { threadId: args.threadId });
163
+ }
164
+
165
+ /**
166
+ * Get all threads in a zone with pagination.
167
+ */
168
+ async getThreads(
169
+ ctx: QueryCtx,
170
+ args: {
171
+ zoneId: string;
172
+ limit?: number;
173
+ includeResolved?: boolean;
174
+ cursor?: string;
175
+ }
176
+ ) {
177
+ return await ctx.runQuery(this.component.lib.getThreads, {
178
+ zoneId: args.zoneId,
179
+ limit: args.limit,
180
+ includeResolved: args.includeResolved,
181
+ cursor: args.cursor,
182
+ });
183
+ }
184
+
185
+ /**
186
+ * Resolve a thread.
187
+ */
188
+ async resolveThread(
189
+ ctx: MutationCtx,
190
+ args: { threadId: string; userId: string }
191
+ ) {
192
+ return await ctx.runMutation(this.component.lib.resolveThread, args);
193
+ }
194
+
195
+ /**
196
+ * Unresolve a thread.
197
+ */
198
+ async unresolveThread(ctx: MutationCtx, args: { threadId: string }) {
199
+ return await ctx.runMutation(this.component.lib.unresolveThread, args);
200
+ }
201
+
202
+ /**
203
+ * Update thread position.
204
+ */
205
+ async updateThreadPosition(
206
+ ctx: MutationCtx,
207
+ args: {
208
+ threadId: string;
209
+ position?: { x: number; y: number; anchor?: string };
210
+ }
211
+ ) {
212
+ return await ctx.runMutation(this.component.lib.updateThreadPosition, args);
213
+ }
214
+
215
+ /**
216
+ * Delete a thread and all its messages.
217
+ */
218
+ async deleteThread(ctx: MutationCtx, args: { threadId: string }) {
219
+ return await ctx.runMutation(this.component.lib.deleteThread, { threadId: args.threadId });
220
+ }
221
+
222
+ // ==========================================================================
223
+ // Message Methods
224
+ // ==========================================================================
225
+
226
+ /**
227
+ * Add a comment to a thread.
228
+ * Returns the message ID and parsed mentions/links.
229
+ */
230
+ async addComment(
231
+ ctx: MutationCtx,
232
+ args: {
233
+ threadId: string;
234
+ authorId: string;
235
+ body: string;
236
+ attachments?: Attachment[];
237
+ }
238
+ ) {
239
+ const result = await ctx.runMutation(this.component.lib.addComment, {
240
+ threadId: args.threadId,
241
+ authorId: args.authorId,
242
+ body: args.body,
243
+ attachments: args.attachments,
244
+ });
245
+
246
+ // Fire callbacks if configured
247
+ if (this.callbacks?.onNewMessage) {
248
+ await this.callbacks.onNewMessage({
249
+ messageId: result.messageId,
250
+ threadId: args.threadId,
251
+ authorId: args.authorId,
252
+ body: args.body,
253
+ mentions: result.mentions,
254
+ });
255
+ }
256
+
257
+ if (this.callbacks?.onMention) {
258
+ for (const mention of result.mentions) {
259
+ await this.callbacks.onMention({
260
+ messageId: result.messageId,
261
+ mentionedUserId: mention.userId,
262
+ authorId: args.authorId,
263
+ body: args.body,
264
+ });
265
+ }
266
+ }
267
+
268
+ return result;
269
+ }
270
+
271
+ /**
272
+ * Get a single message with reactions.
273
+ */
274
+ async getMessage(
275
+ ctx: QueryCtx,
276
+ args: { messageId: string; currentUserId?: string }
277
+ ) {
278
+ return await ctx.runQuery(this.component.lib.getMessage, args);
279
+ }
280
+
281
+ /**
282
+ * Get messages in a thread with pagination.
283
+ */
284
+ async getMessages(
285
+ ctx: QueryCtx,
286
+ args: {
287
+ threadId: string;
288
+ limit?: number;
289
+ cursor?: string;
290
+ order?: "asc" | "desc";
291
+ currentUserId?: string;
292
+ includeDeleted?: boolean;
293
+ }
294
+ ) {
295
+ return await ctx.runQuery(this.component.lib.getMessages, args);
296
+ }
297
+
298
+ /**
299
+ * Edit a message.
300
+ */
301
+ async editMessage(
302
+ ctx: MutationCtx,
303
+ args: { messageId: string; body: string; authorId?: string }
304
+ ) {
305
+ return await ctx.runMutation(this.component.lib.editMessage, args);
306
+ }
307
+
308
+ /**
309
+ * Soft delete a message.
310
+ */
311
+ async deleteMessage(
312
+ ctx: MutationCtx,
313
+ args: { messageId: string; authorId?: string }
314
+ ) {
315
+ return await ctx.runMutation(this.component.lib.deleteMessage, args);
316
+ }
317
+
318
+ // ==========================================================================
319
+ // Reaction Methods
320
+ // ==========================================================================
321
+
322
+ /**
323
+ * Add a reaction to a message.
324
+ */
325
+ async addReaction(
326
+ ctx: MutationCtx,
327
+ args: { messageId: string; userId: string; emoji: string }
328
+ ) {
329
+ return await ctx.runMutation(this.component.lib.addReaction, args);
330
+ }
331
+
332
+ /**
333
+ * Remove a reaction from a message.
334
+ */
335
+ async removeReaction(
336
+ ctx: MutationCtx,
337
+ args: { messageId: string; userId: string; emoji: string }
338
+ ) {
339
+ return await ctx.runMutation(this.component.lib.removeReaction, args);
340
+ }
341
+
342
+ /**
343
+ * Toggle a reaction (add if not present, remove if present).
344
+ */
345
+ async toggleReaction(
346
+ ctx: MutationCtx,
347
+ args: { messageId: string; userId: string; emoji: string }
348
+ ) {
349
+ return await ctx.runMutation(this.component.lib.toggleReaction, args);
350
+ }
351
+
352
+ /**
353
+ * Get all reactions for a message.
354
+ */
355
+ async getReactions(
356
+ ctx: QueryCtx,
357
+ args: { messageId: string; currentUserId?: string }
358
+ ) {
359
+ return await ctx.runQuery(this.component.lib.getReactions, args);
360
+ }
361
+
362
+ // ==========================================================================
363
+ // Typing Indicator Methods
364
+ // ==========================================================================
365
+
366
+ /**
367
+ * Set typing indicator for a user in a thread.
368
+ */
369
+ async setIsTyping(
370
+ ctx: MutationCtx,
371
+ args: { threadId: string; userId: string; isTyping: boolean }
372
+ ) {
373
+ return await ctx.runMutation(this.component.lib.setIsTyping, args);
374
+ }
375
+
376
+ /**
377
+ * Get all users currently typing in a thread.
378
+ */
379
+ async getTypingUsers(
380
+ ctx: QueryCtx,
381
+ args: { threadId: string; excludeUserId?: string }
382
+ ) {
383
+ return await ctx.runQuery(this.component.lib.getTypingUsers, args);
384
+ }
385
+
386
+ /**
387
+ * Clear all typing indicators for a user.
388
+ */
389
+ async clearUserTyping(ctx: MutationCtx, args: { userId: string }) {
390
+ return await ctx.runMutation(this.component.lib.clearUserTyping, args);
391
+ }
392
+
393
+ /**
394
+ * Resolve a message.
395
+ */
396
+ async resolveMessage(
397
+ ctx: MutationCtx,
398
+ args: { messageId: string; userId: string }
399
+ ) {
400
+ return await ctx.runMutation(this.component.lib.resolveMessage, args);
401
+ }
402
+
403
+ /**
404
+ * Unresolve a message.
405
+ */
406
+ async unresolveMessage(ctx: MutationCtx, args: { messageId: string }) {
407
+ return await ctx.runMutation(this.component.lib.unresolveMessage, args);
408
+ }
409
+ }
410
+
411
+ // ============================================================================
412
+ // API Re-export Functions
413
+ // ============================================================================
414
+
415
+ /**
416
+ * Create a re-exportable API for the Comments component.
417
+ *
418
+ * This allows apps to expose comments functionality directly to React clients
419
+ * with authentication. The auth callback is called before each operation.
420
+ *
421
+ * Usage:
422
+ * ```ts
423
+ * // In convex/comments.ts
424
+ * import { exposeApi } from "@your-org/comments";
425
+ * import { components } from "./_generated/api";
426
+ *
427
+ * export const { getThreads, addComment, toggleReaction } = exposeApi(
428
+ * components.comments,
429
+ * {
430
+ * auth: async (ctx, operation) => {
431
+ * const userId = await getAuthUserId(ctx);
432
+ * if (!userId && operation.type !== "read") {
433
+ * throw new Error("Authentication required");
434
+ * }
435
+ * return userId ?? "anonymous";
436
+ * },
437
+ * }
438
+ * );
439
+ * ```
440
+ */
441
+ export function exposeApi(
442
+ component: ComponentApi,
443
+ options: {
444
+ /** Authentication callback - must return user ID or throw */
445
+ auth: AuthCallback;
446
+ /** Optional notification callbacks */
447
+ callbacks?: MentionCallbacks;
448
+ }
449
+ ) {
450
+ return {
451
+ // Zone queries/mutations
452
+ getOrCreateZone: mutationGeneric({
453
+ args: { entityId: v.string(), metadata: v.optional(v.any()) },
454
+ handler: async (ctx, args) => {
455
+ await options.auth(ctx, { type: "create" });
456
+ return await ctx.runMutation(component.lib.getOrCreateZone, args);
457
+ },
458
+ }),
459
+
460
+ getZone: queryGeneric({
461
+ args: { entityId: v.string() },
462
+ handler: async (ctx, args) => {
463
+ await options.auth(ctx, { type: "read" });
464
+ return await ctx.runQuery(component.lib.getZone, args);
465
+ },
466
+ }),
467
+
468
+ // Thread queries/mutations
469
+ addThread: mutationGeneric({
470
+ args: {
471
+ zoneId: v.string(),
472
+ position: v.optional(
473
+ v.object({
474
+ x: v.number(),
475
+ y: v.number(),
476
+ anchor: v.optional(v.string()),
477
+ })
478
+ ),
479
+ metadata: v.optional(v.any()),
480
+ },
481
+ handler: async (ctx, args) => {
482
+ await options.auth(ctx, { type: "create", zoneId: args.zoneId });
483
+ return await ctx.runMutation(component.lib.addThread, {
484
+ zoneId: args.zoneId,
485
+ position: args.position,
486
+ metadata: args.metadata,
487
+ });
488
+ },
489
+ }),
490
+
491
+ getThread: queryGeneric({
492
+ args: { threadId: v.string() },
493
+ handler: async (ctx, args) => {
494
+ await options.auth(ctx, { type: "read", threadId: args.threadId });
495
+ return await ctx.runQuery(component.lib.getThread, { threadId: args.threadId });
496
+ },
497
+ }),
498
+
499
+ getThreads: queryGeneric({
500
+ args: {
501
+ zoneId: v.string(),
502
+ limit: v.optional(v.number()),
503
+ includeResolved: v.optional(v.boolean()),
504
+ cursor: v.optional(v.string()),
505
+ },
506
+ handler: async (ctx, args) => {
507
+ await options.auth(ctx, { type: "read", zoneId: args.zoneId });
508
+ return await ctx.runQuery(component.lib.getThreads, {
509
+ zoneId: args.zoneId,
510
+ limit: args.limit,
511
+ includeResolved: args.includeResolved,
512
+ cursor: args.cursor,
513
+ });
514
+ },
515
+ }),
516
+
517
+ resolveThread: mutationGeneric({
518
+ args: { threadId: v.string() },
519
+ handler: async (ctx, args) => {
520
+ const userId = await options.auth(ctx, { type: "update", threadId: args.threadId });
521
+ return await ctx.runMutation(component.lib.resolveThread, {
522
+ threadId: args.threadId,
523
+ userId,
524
+ });
525
+ },
526
+ }),
527
+
528
+ unresolveThread: mutationGeneric({
529
+ args: { threadId: v.string() },
530
+ handler: async (ctx, args) => {
531
+ await options.auth(ctx, { type: "update", threadId: args.threadId });
532
+ return await ctx.runMutation(component.lib.unresolveThread, { threadId: args.threadId });
533
+ },
534
+ }),
535
+
536
+ // Message queries/mutations
537
+ addComment: mutationGeneric({
538
+ args: {
539
+ threadId: v.string(),
540
+ body: v.string(),
541
+ attachments: v.optional(
542
+ v.array(
543
+ v.object({
544
+ type: v.union(v.literal("url"), v.literal("file"), v.literal("image")),
545
+ url: v.string(),
546
+ name: v.optional(v.string()),
547
+ mimeType: v.optional(v.string()),
548
+ size: v.optional(v.number()),
549
+ })
550
+ )
551
+ ),
552
+ },
553
+ handler: async (ctx, args) => {
554
+ const userId = await options.auth(ctx, { type: "create", threadId: args.threadId });
555
+ const result = await ctx.runMutation(component.lib.addComment, {
556
+ threadId: args.threadId,
557
+ authorId: userId,
558
+ body: args.body,
559
+ attachments: args.attachments,
560
+ });
561
+
562
+ // Fire callbacks
563
+ if (options.callbacks?.onNewMessage) {
564
+ await options.callbacks.onNewMessage({
565
+ messageId: result.messageId,
566
+ threadId: args.threadId,
567
+ authorId: userId,
568
+ body: args.body,
569
+ mentions: result.mentions,
570
+ });
571
+ }
572
+
573
+ if (options.callbacks?.onMention) {
574
+ for (const mention of result.mentions) {
575
+ await options.callbacks.onMention({
576
+ messageId: result.messageId,
577
+ mentionedUserId: mention.userId,
578
+ authorId: userId,
579
+ body: args.body,
580
+ });
581
+ }
582
+ }
583
+
584
+ return result;
585
+ },
586
+ }),
587
+
588
+ getMessage: queryGeneric({
589
+ args: { messageId: v.string() },
590
+ handler: async (ctx, args) => {
591
+ const userId = await options.auth(ctx, { type: "read" }).catch(() => undefined);
592
+ return await ctx.runQuery(component.lib.getMessage, {
593
+ messageId: args.messageId,
594
+ currentUserId: userId,
595
+ });
596
+ },
597
+ }),
598
+
599
+ getMessages: queryGeneric({
600
+ args: {
601
+ threadId: v.string(),
602
+ limit: v.optional(v.number()),
603
+ cursor: v.optional(v.string()),
604
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
605
+ includeDeleted: v.optional(v.boolean()),
606
+ },
607
+ handler: async (ctx, args) => {
608
+ const userId = await options.auth(ctx, { type: "read", threadId: args.threadId }).catch(() => undefined);
609
+ return await ctx.runQuery(component.lib.getMessages, {
610
+ threadId: args.threadId,
611
+ limit: args.limit,
612
+ cursor: args.cursor,
613
+ order: args.order,
614
+ currentUserId: userId,
615
+ includeDeleted: args.includeDeleted,
616
+ });
617
+ },
618
+ }),
619
+
620
+ editMessage: mutationGeneric({
621
+ args: { messageId: v.string(), body: v.string() },
622
+ handler: async (ctx, args) => {
623
+ const userId = await options.auth(ctx, { type: "update", messageId: args.messageId });
624
+ return await ctx.runMutation(component.lib.editMessage, {
625
+ messageId: args.messageId,
626
+ body: args.body,
627
+ authorId: userId,
628
+ });
629
+ },
630
+ }),
631
+
632
+ deleteMessage: mutationGeneric({
633
+ args: { messageId: v.string() },
634
+ handler: async (ctx, args) => {
635
+ const userId = await options.auth(ctx, { type: "delete", messageId: args.messageId });
636
+ return await ctx.runMutation(component.lib.deleteMessage, {
637
+ messageId: args.messageId,
638
+ authorId: userId,
639
+ });
640
+ },
641
+ }),
642
+
643
+ // Reactions
644
+ toggleReaction: mutationGeneric({
645
+ args: { messageId: v.string(), emoji: v.string() },
646
+ handler: async (ctx, args) => {
647
+ const userId = await options.auth(ctx, { type: "react", messageId: args.messageId });
648
+ return await ctx.runMutation(component.lib.toggleReaction, {
649
+ messageId: args.messageId,
650
+ userId,
651
+ emoji: args.emoji,
652
+ });
653
+ },
654
+ }),
655
+
656
+ getReactions: queryGeneric({
657
+ args: { messageId: v.string() },
658
+ handler: async (ctx, args) => {
659
+ const userId = await options.auth(ctx, { type: "read" }).catch(() => undefined);
660
+ return await ctx.runQuery(component.lib.getReactions, {
661
+ messageId: args.messageId,
662
+ currentUserId: userId,
663
+ });
664
+ },
665
+ }),
666
+
667
+ // Typing indicators
668
+ setIsTyping: mutationGeneric({
669
+ args: { threadId: v.string(), isTyping: v.boolean() },
670
+ handler: async (ctx, args) => {
671
+ const userId = await options.auth(ctx, { type: "create", threadId: args.threadId });
672
+ return await ctx.runMutation(component.lib.setIsTyping, {
673
+ threadId: args.threadId,
674
+ userId,
675
+ isTyping: args.isTyping,
676
+ });
677
+ },
678
+ }),
679
+
680
+ getTypingUsers: queryGeneric({
681
+ args: { threadId: v.string() },
682
+ handler: async (ctx, args) => {
683
+ const userId = await options.auth(ctx, { type: "read", threadId: args.threadId }).catch(() => undefined);
684
+ return await ctx.runQuery(component.lib.getTypingUsers, {
685
+ threadId: args.threadId,
686
+ excludeUserId: userId,
687
+ });
688
+ },
689
+ }),
690
+ };
691
+ }
692
+
693
+ // ============================================================================
694
+ // HTTP Route Registration
695
+ // ============================================================================
696
+
697
+ /**
698
+ * Register HTTP routes for the Comments component.
699
+ *
700
+ * Provides REST-like endpoints for the comments API:
701
+ * - GET /comments/zones/:entityId - Get zone by entity ID
702
+ * - GET /comments/threads/:zoneId - Get threads in a zone
703
+ * - GET /comments/messages/:threadId - Get messages in a thread
704
+ *
705
+ * Usage:
706
+ * ```ts
707
+ * // In convex/http.ts
708
+ * import { registerRoutes } from "@your-org/comments";
709
+ * import { components } from "./_generated/api";
710
+ *
711
+ * const http = httpRouter();
712
+ * registerRoutes(http, components.comments, { pathPrefix: "/api/comments" });
713
+ * export default http;
714
+ * ```
715
+ */
716
+ export function registerRoutes(
717
+ http: HttpRouter,
718
+ component: ComponentApi,
719
+ options: { pathPrefix?: string } = {}
720
+ ) {
721
+ const prefix = options.pathPrefix ?? "/comments";
722
+
723
+ // Get zone by entity ID
724
+ http.route({
725
+ path: `${prefix}/zones`,
726
+ method: "GET",
727
+ handler: httpActionGeneric(async (ctx, request) => {
728
+ const entityId = new URL(request.url).searchParams.get("entityId");
729
+ if (!entityId) {
730
+ return new Response(
731
+ JSON.stringify({ error: "entityId parameter required" }),
732
+ { status: 400, headers: { "Content-Type": "application/json" } }
733
+ );
734
+ }
735
+
736
+ const zone = await ctx.runQuery(component.lib.getZone, { entityId });
737
+ return new Response(JSON.stringify(zone), {
738
+ status: 200,
739
+ headers: { "Content-Type": "application/json" },
740
+ });
741
+ }),
742
+ });
743
+
744
+ // Get threads in a zone
745
+ http.route({
746
+ path: `${prefix}/threads`,
747
+ method: "GET",
748
+ handler: httpActionGeneric(async (ctx, request) => {
749
+ const url = new URL(request.url);
750
+ const zoneId = url.searchParams.get("zoneId");
751
+ if (!zoneId) {
752
+ return new Response(
753
+ JSON.stringify({ error: "zoneId parameter required" }),
754
+ { status: 400, headers: { "Content-Type": "application/json" } }
755
+ );
756
+ }
757
+
758
+ const limit = url.searchParams.get("limit");
759
+ const cursor = url.searchParams.get("cursor");
760
+ const includeResolved = url.searchParams.get("includeResolved");
761
+
762
+ const result = await ctx.runQuery(component.lib.getThreads, {
763
+ zoneId,
764
+ limit: limit ? parseInt(limit, 10) : undefined,
765
+ cursor: cursor ?? undefined,
766
+ includeResolved: includeResolved === "true",
767
+ });
768
+
769
+ return new Response(JSON.stringify(result), {
770
+ status: 200,
771
+ headers: { "Content-Type": "application/json" },
772
+ });
773
+ }),
774
+ });
775
+
776
+ // Get messages in a thread
777
+ http.route({
778
+ path: `${prefix}/messages`,
779
+ method: "GET",
780
+ handler: httpActionGeneric(async (ctx, request) => {
781
+ const url = new URL(request.url);
782
+ const threadId = url.searchParams.get("threadId");
783
+ if (!threadId) {
784
+ return new Response(
785
+ JSON.stringify({ error: "threadId parameter required" }),
786
+ { status: 400, headers: { "Content-Type": "application/json" } }
787
+ );
788
+ }
789
+
790
+ const limit = url.searchParams.get("limit");
791
+ const cursor = url.searchParams.get("cursor");
792
+ const order = url.searchParams.get("order") as "asc" | "desc" | null;
793
+
794
+ const result = await ctx.runQuery(component.lib.getMessages, {
795
+ threadId,
796
+ limit: limit ? parseInt(limit, 10) : undefined,
797
+ cursor: cursor ?? undefined,
798
+ order: order ?? undefined,
799
+ });
800
+
801
+ return new Response(JSON.stringify(result), {
802
+ status: 200,
803
+ headers: { "Content-Type": "application/json" },
804
+ });
805
+ }),
806
+ });
807
+ }
808
+
809
+ // ============================================================================
810
+ // Default Export
811
+ // ============================================================================
812
+
813
+ export default Comments;