@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,476 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+ import { attachmentValidator, mentionValidator, linkValidator } from "./schema.js";
4
+
5
+ // ============================================================================
6
+ // Message Validators
7
+ // ============================================================================
8
+
9
+ const messageValidator = v.object({
10
+ _id: v.id("messages"),
11
+ _creationTime: v.number(),
12
+ threadId: v.id("threads"),
13
+ authorId: v.string(),
14
+ body: v.string(),
15
+ mentions: v.array(mentionValidator),
16
+ links: v.array(linkValidator),
17
+ attachments: v.array(attachmentValidator),
18
+ isEdited: v.boolean(),
19
+ isDeleted: v.boolean(),
20
+ resolved: v.optional(v.boolean()),
21
+ resolvedBy: v.optional(v.string()),
22
+ resolvedAt: v.optional(v.number()),
23
+ createdAt: v.number(),
24
+ editedAt: v.optional(v.number()),
25
+ });
26
+
27
+ const reactionSummaryValidator = v.object({
28
+ emoji: v.string(),
29
+ count: v.number(),
30
+ users: v.array(v.string()),
31
+ includesMe: v.boolean(),
32
+ });
33
+
34
+ const messageWithReactionsValidator = v.object({
35
+ message: messageValidator,
36
+ reactions: v.array(reactionSummaryValidator),
37
+ });
38
+
39
+ // ============================================================================
40
+ // Mention & Link Parsing
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Parse mentions from message body.
45
+ * Supports @userId format where userId can contain alphanumeric, underscore, hyphen.
46
+ */
47
+ function parseMentions(body: string): Array<{ userId: string; start: number; end: number }> {
48
+ const mentions: Array<{ userId: string; start: number; end: number }> = [];
49
+ // Match @mentions - supports various ID formats
50
+ const mentionRegex = /@([a-zA-Z0-9_\-:]+)/g;
51
+ let match;
52
+
53
+ while ((match = mentionRegex.exec(body)) !== null) {
54
+ mentions.push({
55
+ userId: match[1],
56
+ start: match.index,
57
+ end: match.index + match[0].length,
58
+ });
59
+ }
60
+
61
+ return mentions;
62
+ }
63
+
64
+ /**
65
+ * Parse links from message body.
66
+ */
67
+ function parseLinks(body: string): Array<{ url: string; start: number; end: number }> {
68
+ const links: Array<{ url: string; start: number; end: number }> = [];
69
+ // Simple URL regex - matches http(s) URLs
70
+ const urlRegex = /(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/g;
71
+ let match;
72
+
73
+ while ((match = urlRegex.exec(body)) !== null) {
74
+ links.push({
75
+ url: match[1],
76
+ start: match.index,
77
+ end: match.index + match[0].length,
78
+ });
79
+ }
80
+
81
+ return links;
82
+ }
83
+
84
+ // ============================================================================
85
+ // Message Functions
86
+ // ============================================================================
87
+
88
+ /**
89
+ * Add a new comment/message to a thread.
90
+ * Parses mentions and links from the body.
91
+ */
92
+ export const addComment = mutation({
93
+ args: {
94
+ threadId: v.id("threads"),
95
+ authorId: v.string(),
96
+ body: v.string(),
97
+ attachments: v.optional(v.array(attachmentValidator)),
98
+ },
99
+ returns: v.object({
100
+ messageId: v.id("messages"),
101
+ mentions: v.array(mentionValidator),
102
+ links: v.array(linkValidator),
103
+ }),
104
+ handler: async (ctx, args) => {
105
+ // Verify thread exists
106
+ const thread = await ctx.db.get(args.threadId);
107
+ if (!thread) {
108
+ throw new Error(`Thread ${args.threadId} not found`);
109
+ }
110
+
111
+ // Parse mentions and links
112
+ const mentions = parseMentions(args.body);
113
+ const links = parseLinks(args.body);
114
+
115
+ const now = Date.now();
116
+
117
+ // Insert message
118
+ const messageId = await ctx.db.insert("messages", {
119
+ threadId: args.threadId,
120
+ authorId: args.authorId,
121
+ body: args.body,
122
+ mentions,
123
+ links,
124
+ attachments: args.attachments ?? [],
125
+ isEdited: false,
126
+ isDeleted: false,
127
+ createdAt: now,
128
+ });
129
+
130
+ // Update thread's last activity
131
+ await ctx.db.patch(args.threadId, {
132
+ lastActivityAt: now,
133
+ });
134
+
135
+ // Clear typing indicator for this user
136
+ const typingIndicator = await ctx.db
137
+ .query("typingIndicators")
138
+ .withIndex("threadId_userId", (q) =>
139
+ q.eq("threadId", args.threadId).eq("userId", args.authorId)
140
+ )
141
+ .first();
142
+
143
+ if (typingIndicator) {
144
+ await ctx.db.delete(typingIndicator._id);
145
+ }
146
+
147
+ return { messageId, mentions, links };
148
+ },
149
+ });
150
+
151
+ /**
152
+ * Get a single message by ID.
153
+ */
154
+ export const getMessage = query({
155
+ args: {
156
+ messageId: v.id("messages"),
157
+ currentUserId: v.optional(v.string()),
158
+ },
159
+ returns: v.union(v.null(), messageWithReactionsValidator),
160
+ handler: async (ctx, args) => {
161
+ const message = await ctx.db.get(args.messageId);
162
+ if (!message) {
163
+ return null;
164
+ }
165
+
166
+ // Get reactions grouped by emoji
167
+ const reactions = await ctx.db
168
+ .query("reactions")
169
+ .withIndex("messageId", (q) => q.eq("messageId", args.messageId))
170
+ .collect();
171
+
172
+ const reactionMap = new Map<string, { count: number; users: string[] }>();
173
+ for (const reaction of reactions) {
174
+ const existing = reactionMap.get(reaction.emoji);
175
+ if (existing) {
176
+ existing.count++;
177
+ existing.users.push(reaction.userId);
178
+ } else {
179
+ reactionMap.set(reaction.emoji, { count: 1, users: [reaction.userId] });
180
+ }
181
+ }
182
+
183
+ const reactionSummaries = Array.from(reactionMap.entries()).map(
184
+ ([emoji, data]) => ({
185
+ emoji,
186
+ count: data.count,
187
+ users: data.users,
188
+ includesMe: args.currentUserId
189
+ ? data.users.includes(args.currentUserId)
190
+ : false,
191
+ })
192
+ );
193
+
194
+ return {
195
+ message,
196
+ reactions: reactionSummaries,
197
+ };
198
+ },
199
+ });
200
+
201
+ /**
202
+ * Get messages in a thread with pagination.
203
+ * Uses cursor-based pagination for efficiency.
204
+ */
205
+ export const getMessages = query({
206
+ args: {
207
+ threadId: v.id("threads"),
208
+ limit: v.optional(v.number()),
209
+ cursor: v.optional(v.string()),
210
+ order: v.optional(v.union(v.literal("asc"), v.literal("desc"))),
211
+ currentUserId: v.optional(v.string()),
212
+ includeDeleted: v.optional(v.boolean()),
213
+ },
214
+ returns: v.object({
215
+ messages: v.array(messageWithReactionsValidator),
216
+ nextCursor: v.optional(v.string()),
217
+ hasMore: v.boolean(),
218
+ }),
219
+ handler: async (ctx, args) => {
220
+ const limit = args.limit ?? 50;
221
+ const order = args.order ?? "asc";
222
+ const includeDeleted = args.includeDeleted ?? false;
223
+
224
+ // Build query
225
+ let query;
226
+ if (args.cursor) {
227
+ const cursorTime = parseInt(args.cursor, 10);
228
+ if (order === "asc") {
229
+ query = ctx.db
230
+ .query("messages")
231
+ .withIndex("threadId_createdAt", (q) =>
232
+ q.eq("threadId", args.threadId).gt("createdAt", cursorTime)
233
+ )
234
+ .order("asc");
235
+ } else {
236
+ query = ctx.db
237
+ .query("messages")
238
+ .withIndex("threadId_createdAt", (q) =>
239
+ q.eq("threadId", args.threadId).lt("createdAt", cursorTime)
240
+ )
241
+ .order("desc");
242
+ }
243
+ } else {
244
+ query = ctx.db
245
+ .query("messages")
246
+ .withIndex("threadId_createdAt", (q) => q.eq("threadId", args.threadId))
247
+ .order(order);
248
+ }
249
+
250
+ // Fetch one extra to check if there are more
251
+ const messages = await query.take(limit + 1);
252
+
253
+ // Filter deleted if needed
254
+ const filteredMessages = includeDeleted
255
+ ? messages
256
+ : messages.filter((m) => !m.isDeleted);
257
+
258
+ const hasMore = filteredMessages.length > limit;
259
+ const resultMessages = filteredMessages.slice(0, limit);
260
+
261
+ // Get reactions for each message
262
+ const messagesWithReactions = await Promise.all(
263
+ resultMessages.map(async (message) => {
264
+ const reactions = await ctx.db
265
+ .query("reactions")
266
+ .withIndex("messageId", (q) => q.eq("messageId", message._id))
267
+ .collect();
268
+
269
+ const reactionMap = new Map<string, { count: number; users: string[] }>();
270
+ for (const reaction of reactions) {
271
+ const existing = reactionMap.get(reaction.emoji);
272
+ if (existing) {
273
+ existing.count++;
274
+ existing.users.push(reaction.userId);
275
+ } else {
276
+ reactionMap.set(reaction.emoji, { count: 1, users: [reaction.userId] });
277
+ }
278
+ }
279
+
280
+ const reactionSummaries = Array.from(reactionMap.entries()).map(
281
+ ([emoji, data]) => ({
282
+ emoji,
283
+ count: data.count,
284
+ users: data.users,
285
+ includesMe: args.currentUserId
286
+ ? data.users.includes(args.currentUserId)
287
+ : false,
288
+ })
289
+ );
290
+
291
+ return {
292
+ message,
293
+ reactions: reactionSummaries,
294
+ };
295
+ })
296
+ );
297
+
298
+ // Generate next cursor
299
+ const lastMessage = resultMessages[resultMessages.length - 1];
300
+ const nextCursor = hasMore && lastMessage
301
+ ? lastMessage.createdAt.toString()
302
+ : undefined;
303
+
304
+ return {
305
+ messages: messagesWithReactions,
306
+ nextCursor,
307
+ hasMore,
308
+ };
309
+ },
310
+ });
311
+
312
+ /**
313
+ * Edit a message.
314
+ */
315
+ export const editMessage = mutation({
316
+ args: {
317
+ messageId: v.id("messages"),
318
+ body: v.string(),
319
+ /** Optional: User ID to verify ownership */
320
+ authorId: v.optional(v.string()),
321
+ },
322
+ returns: v.object({
323
+ mentions: v.array(mentionValidator),
324
+ links: v.array(linkValidator),
325
+ }),
326
+ handler: async (ctx, args) => {
327
+ const message = await ctx.db.get(args.messageId);
328
+ if (!message) {
329
+ throw new Error(`Message ${args.messageId} not found`);
330
+ }
331
+
332
+ if (message.isDeleted) {
333
+ throw new Error("Cannot edit a deleted message");
334
+ }
335
+
336
+ // Optionally verify ownership
337
+ if (args.authorId && message.authorId !== args.authorId) {
338
+ throw new Error("You can only edit your own messages");
339
+ }
340
+
341
+ // Re-parse mentions and links
342
+ const mentions = parseMentions(args.body);
343
+ const links = parseLinks(args.body);
344
+
345
+ await ctx.db.patch(args.messageId, {
346
+ body: args.body,
347
+ mentions,
348
+ links,
349
+ isEdited: true,
350
+ editedAt: Date.now(),
351
+ });
352
+
353
+ // Update thread activity
354
+ const thread = await ctx.db.get(message.threadId);
355
+ if (thread) {
356
+ await ctx.db.patch(message.threadId, {
357
+ lastActivityAt: Date.now(),
358
+ });
359
+ }
360
+
361
+ return { mentions, links };
362
+ },
363
+ });
364
+
365
+ /**
366
+ * Soft delete a message.
367
+ */
368
+ export const deleteMessage = mutation({
369
+ args: {
370
+ messageId: v.id("messages"),
371
+ /** Optional: User ID to verify ownership */
372
+ authorId: v.optional(v.string()),
373
+ },
374
+ returns: v.null(),
375
+ handler: async (ctx, args) => {
376
+ const message = await ctx.db.get(args.messageId);
377
+ if (!message) {
378
+ throw new Error(`Message ${args.messageId} not found`);
379
+ }
380
+
381
+ // Optionally verify ownership
382
+ if (args.authorId && message.authorId !== args.authorId) {
383
+ throw new Error("You can only delete your own messages");
384
+ }
385
+
386
+ await ctx.db.patch(args.messageId, {
387
+ isDeleted: true,
388
+ body: "[deleted]",
389
+ mentions: [],
390
+ links: [],
391
+ attachments: [],
392
+ });
393
+
394
+ return null;
395
+ },
396
+ });
397
+
398
+ /**
399
+ * Hard delete a message (permanently remove).
400
+ */
401
+ export const permanentlyDeleteMessage = mutation({
402
+ args: {
403
+ messageId: v.id("messages"),
404
+ },
405
+ returns: v.null(),
406
+ handler: async (ctx, args) => {
407
+ // Delete all reactions on this message
408
+ const reactions = await ctx.db
409
+ .query("reactions")
410
+ .withIndex("messageId", (q) => q.eq("messageId", args.messageId))
411
+ .collect();
412
+
413
+ for (const reaction of reactions) {
414
+ await ctx.db.delete(reaction._id);
415
+ }
416
+
417
+ // Delete the message
418
+ await ctx.db.delete(args.messageId);
419
+
420
+ return null;
421
+ },
422
+ });
423
+
424
+ /**
425
+ * Resolve a message (mark as addressed/completed).
426
+ */
427
+ export const resolveMessage = mutation({
428
+ args: {
429
+ messageId: v.id("messages"),
430
+ userId: v.string(),
431
+ },
432
+ returns: v.null(),
433
+ handler: async (ctx, args) => {
434
+ const message = await ctx.db.get(args.messageId);
435
+ if (!message) {
436
+ throw new Error(`Message ${args.messageId} not found`);
437
+ }
438
+
439
+ if (message.isDeleted) {
440
+ throw new Error("Cannot resolve a deleted message");
441
+ }
442
+
443
+ await ctx.db.patch(args.messageId, {
444
+ resolved: true,
445
+ resolvedBy: args.userId,
446
+ resolvedAt: Date.now(),
447
+ });
448
+
449
+ return null;
450
+ },
451
+ });
452
+
453
+ /**
454
+ * Unresolve a message (mark as not addressed).
455
+ */
456
+ export const unresolveMessage = mutation({
457
+ args: {
458
+ messageId: v.id("messages"),
459
+ },
460
+ returns: v.null(),
461
+ handler: async (ctx, args) => {
462
+ const message = await ctx.db.get(args.messageId);
463
+ if (!message) {
464
+ throw new Error(`Message ${args.messageId} not found`);
465
+ }
466
+
467
+ await ctx.db.patch(args.messageId, {
468
+ resolved: undefined,
469
+ resolvedBy: undefined,
470
+ resolvedAt: undefined,
471
+ });
472
+
473
+ return null;
474
+ },
475
+ });
476
+
@@ -0,0 +1,222 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+
4
+ // ============================================================================
5
+ // Reaction Functions
6
+ // ============================================================================
7
+
8
+ /**
9
+ * Add a reaction to a message.
10
+ * If the user already has the same reaction, this is a no-op.
11
+ */
12
+ export const addReaction = mutation({
13
+ args: {
14
+ messageId: v.id("messages"),
15
+ userId: v.string(),
16
+ emoji: v.string(),
17
+ },
18
+ returns: v.union(v.id("reactions"), v.null()),
19
+ handler: async (ctx, args) => {
20
+ // Verify message exists
21
+ const message = await ctx.db.get(args.messageId);
22
+ if (!message) {
23
+ throw new Error(`Message ${args.messageId} not found`);
24
+ }
25
+
26
+ if (message.isDeleted) {
27
+ throw new Error("Cannot react to a deleted message");
28
+ }
29
+
30
+ // Check if reaction already exists
31
+ const existing = await ctx.db
32
+ .query("reactions")
33
+ .withIndex("messageId_emoji_userId", (q) =>
34
+ q
35
+ .eq("messageId", args.messageId)
36
+ .eq("emoji", args.emoji)
37
+ .eq("userId", args.userId)
38
+ )
39
+ .first();
40
+
41
+ if (existing) {
42
+ // Already reacted with this emoji
43
+ return null;
44
+ }
45
+
46
+ // Add reaction
47
+ const reactionId = await ctx.db.insert("reactions", {
48
+ messageId: args.messageId,
49
+ userId: args.userId,
50
+ emoji: args.emoji,
51
+ createdAt: Date.now(),
52
+ });
53
+
54
+ // Update thread activity
55
+ const thread = await ctx.db.get(message.threadId);
56
+ if (thread) {
57
+ await ctx.db.patch(message.threadId, {
58
+ lastActivityAt: Date.now(),
59
+ });
60
+ }
61
+
62
+ return reactionId;
63
+ },
64
+ });
65
+
66
+ /**
67
+ * Remove a reaction from a message.
68
+ */
69
+ export const removeReaction = mutation({
70
+ args: {
71
+ messageId: v.id("messages"),
72
+ userId: v.string(),
73
+ emoji: v.string(),
74
+ },
75
+ returns: v.boolean(),
76
+ handler: async (ctx, args) => {
77
+ // Find the reaction
78
+ const reaction = await ctx.db
79
+ .query("reactions")
80
+ .withIndex("messageId_emoji_userId", (q) =>
81
+ q
82
+ .eq("messageId", args.messageId)
83
+ .eq("emoji", args.emoji)
84
+ .eq("userId", args.userId)
85
+ )
86
+ .first();
87
+
88
+ if (!reaction) {
89
+ return false;
90
+ }
91
+
92
+ await ctx.db.delete(reaction._id);
93
+ return true;
94
+ },
95
+ });
96
+
97
+ /**
98
+ * Toggle a reaction (add if not present, remove if present).
99
+ */
100
+ export const toggleReaction = mutation({
101
+ args: {
102
+ messageId: v.id("messages"),
103
+ userId: v.string(),
104
+ emoji: v.string(),
105
+ },
106
+ returns: v.object({
107
+ added: v.boolean(),
108
+ reactionId: v.optional(v.id("reactions")),
109
+ }),
110
+ handler: async (ctx, args) => {
111
+ // Verify message exists
112
+ const message = await ctx.db.get(args.messageId);
113
+ if (!message) {
114
+ throw new Error(`Message ${args.messageId} not found`);
115
+ }
116
+
117
+ if (message.isDeleted) {
118
+ throw new Error("Cannot react to a deleted message");
119
+ }
120
+
121
+ // Check if reaction already exists
122
+ const existing = await ctx.db
123
+ .query("reactions")
124
+ .withIndex("messageId_emoji_userId", (q) =>
125
+ q
126
+ .eq("messageId", args.messageId)
127
+ .eq("emoji", args.emoji)
128
+ .eq("userId", args.userId)
129
+ )
130
+ .first();
131
+
132
+ if (existing) {
133
+ // Remove existing reaction
134
+ await ctx.db.delete(existing._id);
135
+ return { added: false, reactionId: undefined };
136
+ }
137
+
138
+ // Add new reaction
139
+ const reactionId = await ctx.db.insert("reactions", {
140
+ messageId: args.messageId,
141
+ userId: args.userId,
142
+ emoji: args.emoji,
143
+ createdAt: Date.now(),
144
+ });
145
+
146
+ // Update thread activity
147
+ const thread = await ctx.db.get(message.threadId);
148
+ if (thread) {
149
+ await ctx.db.patch(message.threadId, {
150
+ lastActivityAt: Date.now(),
151
+ });
152
+ }
153
+
154
+ return { added: true, reactionId };
155
+ },
156
+ });
157
+
158
+ /**
159
+ * Get all reactions for a message.
160
+ */
161
+ export const getReactions = query({
162
+ args: {
163
+ messageId: v.id("messages"),
164
+ currentUserId: v.optional(v.string()),
165
+ },
166
+ returns: v.array(
167
+ v.object({
168
+ emoji: v.string(),
169
+ count: v.number(),
170
+ users: v.array(v.string()),
171
+ includesMe: v.boolean(),
172
+ })
173
+ ),
174
+ handler: async (ctx, args) => {
175
+ const reactions = await ctx.db
176
+ .query("reactions")
177
+ .withIndex("messageId", (q) => q.eq("messageId", args.messageId))
178
+ .collect();
179
+
180
+ // Group by emoji
181
+ const reactionMap = new Map<string, { count: number; users: string[] }>();
182
+ for (const reaction of reactions) {
183
+ const existing = reactionMap.get(reaction.emoji);
184
+ if (existing) {
185
+ existing.count++;
186
+ existing.users.push(reaction.userId);
187
+ } else {
188
+ reactionMap.set(reaction.emoji, { count: 1, users: [reaction.userId] });
189
+ }
190
+ }
191
+
192
+ return Array.from(reactionMap.entries()).map(([emoji, data]) => ({
193
+ emoji,
194
+ count: data.count,
195
+ users: data.users,
196
+ includesMe: args.currentUserId
197
+ ? data.users.includes(args.currentUserId)
198
+ : false,
199
+ }));
200
+ },
201
+ });
202
+
203
+ /**
204
+ * Get users who reacted with a specific emoji.
205
+ */
206
+ export const getReactionUsers = query({
207
+ args: {
208
+ messageId: v.id("messages"),
209
+ emoji: v.string(),
210
+ },
211
+ returns: v.array(v.string()),
212
+ handler: async (ctx, args) => {
213
+ const reactions = await ctx.db
214
+ .query("reactions")
215
+ .withIndex("messageId_emoji", (q) =>
216
+ q.eq("messageId", args.messageId).eq("emoji", args.emoji)
217
+ )
218
+ .collect();
219
+
220
+ return reactions.map((r) => r.userId);
221
+ },
222
+ });