@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,169 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+
4
+ /**
5
+ * Comments Component Schema
6
+ *
7
+ * Data model:
8
+ * - Zones: Container for threads, tied to an external entity (e.g., a document)
9
+ * - Threads: Container for messages within a zone
10
+ * - Messages: Individual comments with body, mentions, attachments, resolved state
11
+ * - Reactions: Emoji reactions on messages
12
+ * - TypingIndicators: Real-time typing status with auto-expiry
13
+ */
14
+
15
+ // ============================================================================
16
+ // Validators (exported for use in function args/returns)
17
+ // ============================================================================
18
+
19
+ /** Attachment metadata - URLs, file references, etc. */
20
+ export const attachmentValidator = v.object({
21
+ type: v.union(v.literal("url"), v.literal("file"), v.literal("image")),
22
+ url: v.string(),
23
+ name: v.optional(v.string()),
24
+ mimeType: v.optional(v.string()),
25
+ size: v.optional(v.number()),
26
+ });
27
+
28
+ /** Mention extracted from message body */
29
+ export const mentionValidator = v.object({
30
+ userId: v.string(),
31
+ /** Start position in the body text */
32
+ start: v.number(),
33
+ /** End position in the body text */
34
+ end: v.number(),
35
+ });
36
+
37
+ /** Link extracted from message body */
38
+ export const linkValidator = v.object({
39
+ url: v.string(),
40
+ start: v.number(),
41
+ end: v.number(),
42
+ });
43
+
44
+ // ============================================================================
45
+ // Schema Definition
46
+ // ============================================================================
47
+
48
+ export default defineSchema({
49
+ /**
50
+ * Zones - Container for threads, tied to an external entity
51
+ *
52
+ * A zone represents a commentable area (e.g., a document, a task, a post).
53
+ * The `entityId` is a string representation of whatever the parent app uses
54
+ * to identify the entity (could be a Convex ID, UUID, slug, etc.)
55
+ */
56
+ zones: defineTable({
57
+ /** External entity ID this zone belongs to (stored as string for flexibility) */
58
+ entityId: v.string(),
59
+ /** Optional metadata about the zone */
60
+ metadata: v.optional(v.any()),
61
+ /** Timestamp when zone was created */
62
+ createdAt: v.number(),
63
+ })
64
+ .index("entityId", ["entityId"]),
65
+
66
+ /**
67
+ * Threads - Container for messages within a zone
68
+ *
69
+ * Threads group related messages together. A zone can have multiple threads.
70
+ */
71
+ threads: defineTable({
72
+ /** Zone this thread belongs to */
73
+ zoneId: v.id("zones"),
74
+ /** Whether the entire thread is resolved */
75
+ resolved: v.boolean(),
76
+ /** User who resolved the thread (if resolved) */
77
+ resolvedBy: v.optional(v.string()),
78
+ /** When the thread was resolved */
79
+ resolvedAt: v.optional(v.number()),
80
+ /** Timestamp when thread was created */
81
+ createdAt: v.number(),
82
+ /** Timestamp of last activity (new message, reaction, etc.) */
83
+ lastActivityAt: v.number(),
84
+ /** Optional position data for positioned comments (e.g., x, y coordinates) */
85
+ position: v.optional(v.object({
86
+ x: v.number(),
87
+ y: v.number(),
88
+ /** Optional anchor data (e.g., element ID, text range) */
89
+ anchor: v.optional(v.string()),
90
+ })),
91
+ /** Optional metadata */
92
+ metadata: v.optional(v.any()),
93
+ })
94
+ .index("zoneId", ["zoneId"])
95
+ .index("zoneId_lastActivity", ["zoneId", "lastActivityAt"])
96
+ .index("zoneId_resolved", ["zoneId", "resolved"]),
97
+
98
+ /**
99
+ * Messages - Individual comments within a thread
100
+ */
101
+ messages: defineTable({
102
+ /** Thread this message belongs to */
103
+ threadId: v.id("threads"),
104
+ /** User who authored this message (stored as string for flexibility) */
105
+ authorId: v.string(),
106
+ /** Message body (plain text or markdown) */
107
+ body: v.string(),
108
+ /** Parsed mentions from the body */
109
+ mentions: v.array(mentionValidator),
110
+ /** Parsed links from the body */
111
+ links: v.array(linkValidator),
112
+ /** Attachments on this message */
113
+ attachments: v.array(attachmentValidator),
114
+ /** Whether this message has been edited */
115
+ isEdited: v.boolean(),
116
+ /** Whether this message is deleted (soft delete) */
117
+ isDeleted: v.boolean(),
118
+ /** Whether this message is resolved */
119
+ resolved: v.optional(v.boolean()),
120
+ /** User who resolved the message */
121
+ resolvedBy: v.optional(v.string()),
122
+ /** When the message was resolved */
123
+ resolvedAt: v.optional(v.number()),
124
+ /** Timestamp when created */
125
+ createdAt: v.number(),
126
+ /** Timestamp when last edited */
127
+ editedAt: v.optional(v.number()),
128
+ })
129
+ .index("threadId", ["threadId"])
130
+ .index("threadId_createdAt", ["threadId", "createdAt"])
131
+ .index("authorId", ["authorId"]),
132
+
133
+ /**
134
+ * Reactions - Emoji reactions on messages
135
+ */
136
+ reactions: defineTable({
137
+ /** Message this reaction is on */
138
+ messageId: v.id("messages"),
139
+ /** User who reacted */
140
+ userId: v.string(),
141
+ /** Emoji used (e.g., "👍", "❤️", "🎉") */
142
+ emoji: v.string(),
143
+ /** When the reaction was added */
144
+ createdAt: v.number(),
145
+ })
146
+ .index("messageId", ["messageId"])
147
+ .index("messageId_emoji", ["messageId", "emoji"])
148
+ .index("messageId_userId", ["messageId", "userId"])
149
+ .index("messageId_emoji_userId", ["messageId", "emoji", "userId"]),
150
+
151
+ /**
152
+ * TypingIndicators - Real-time typing status
153
+ *
154
+ * These records auto-expire after a timeout (handled by scheduled function)
155
+ */
156
+ typingIndicators: defineTable({
157
+ /** Thread where user is typing */
158
+ threadId: v.id("threads"),
159
+ /** User who is typing */
160
+ userId: v.string(),
161
+ /** When the typing indicator was last updated */
162
+ updatedAt: v.number(),
163
+ /** When this indicator should expire (for cleanup) */
164
+ expiresAt: v.number(),
165
+ })
166
+ .index("threadId", ["threadId"])
167
+ .index("threadId_userId", ["threadId", "userId"])
168
+ .index("expiresAt", ["expiresAt"]),
169
+ });
@@ -0,0 +1,319 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+
4
+ // ============================================================================
5
+ // Thread Validators
6
+ // ============================================================================
7
+
8
+ const positionValidator = v.object({
9
+ x: v.number(),
10
+ y: v.number(),
11
+ anchor: v.optional(v.string()),
12
+ });
13
+
14
+ const threadValidator = v.object({
15
+ _id: v.id("threads"),
16
+ _creationTime: v.number(),
17
+ zoneId: v.id("zones"),
18
+ resolved: v.boolean(),
19
+ resolvedBy: v.optional(v.string()),
20
+ resolvedAt: v.optional(v.number()),
21
+ createdAt: v.number(),
22
+ lastActivityAt: v.number(),
23
+ position: v.optional(positionValidator),
24
+ metadata: v.optional(v.any()),
25
+ });
26
+
27
+ const messagePreviewValidator = v.object({
28
+ _id: v.id("messages"),
29
+ body: v.string(),
30
+ authorId: v.string(),
31
+ createdAt: v.number(),
32
+ });
33
+
34
+ const threadWithPreviewValidator = v.object({
35
+ thread: threadValidator,
36
+ firstMessage: v.union(v.null(), messagePreviewValidator),
37
+ messageCount: v.number(),
38
+ });
39
+
40
+ // ============================================================================
41
+ // Thread Functions
42
+ // ============================================================================
43
+
44
+ /**
45
+ * Create a new thread in a zone.
46
+ */
47
+ export const addThread = mutation({
48
+ args: {
49
+ zoneId: v.id("zones"),
50
+ position: v.optional(positionValidator),
51
+ metadata: v.optional(v.any()),
52
+ },
53
+ returns: v.id("threads"),
54
+ handler: async (ctx, args) => {
55
+ // Verify zone exists
56
+ const zone = await ctx.db.get(args.zoneId);
57
+ if (!zone) {
58
+ throw new Error(`Zone ${args.zoneId} not found`);
59
+ }
60
+
61
+ const now = Date.now();
62
+ const threadId = await ctx.db.insert("threads", {
63
+ zoneId: args.zoneId,
64
+ resolved: false,
65
+ createdAt: now,
66
+ lastActivityAt: now,
67
+ position: args.position,
68
+ metadata: args.metadata,
69
+ });
70
+
71
+ return threadId;
72
+ },
73
+ });
74
+
75
+ /**
76
+ * Get a thread by ID.
77
+ */
78
+ export const getThread = query({
79
+ args: {
80
+ threadId: v.id("threads"),
81
+ },
82
+ returns: v.union(v.null(), threadValidator),
83
+ handler: async (ctx, args) => {
84
+ return await ctx.db.get(args.threadId);
85
+ },
86
+ });
87
+
88
+ /**
89
+ * Get all threads in a zone with optional pagination and filtering.
90
+ */
91
+ export const getThreads = query({
92
+ args: {
93
+ zoneId: v.id("zones"),
94
+ limit: v.optional(v.number()),
95
+ includeResolved: v.optional(v.boolean()),
96
+ cursor: v.optional(v.string()),
97
+ },
98
+ returns: v.object({
99
+ threads: v.array(threadWithPreviewValidator),
100
+ nextCursor: v.optional(v.string()),
101
+ hasMore: v.boolean(),
102
+ }),
103
+ handler: async (ctx, args) => {
104
+ const limit = args.limit ?? 50;
105
+ const includeResolved = args.includeResolved ?? true;
106
+
107
+ // Build query
108
+ let query = ctx.db
109
+ .query("threads")
110
+ .withIndex("zoneId_lastActivity", (q) => q.eq("zoneId", args.zoneId))
111
+ .order("desc");
112
+
113
+ // Apply cursor if provided
114
+ if (args.cursor) {
115
+ // Cursor is the lastActivityAt timestamp
116
+ const cursorTime = parseInt(args.cursor, 10);
117
+ query = ctx.db
118
+ .query("threads")
119
+ .withIndex("zoneId_lastActivity", (q) =>
120
+ q.eq("zoneId", args.zoneId).lt("lastActivityAt", cursorTime)
121
+ )
122
+ .order("desc");
123
+ }
124
+
125
+ // Fetch one extra to check if there are more
126
+ const threads = await query.take(limit + 1);
127
+
128
+ // Filter by resolved status if needed
129
+ const filteredThreads = includeResolved
130
+ ? threads
131
+ : threads.filter((t) => !t.resolved);
132
+
133
+ const hasMore = filteredThreads.length > limit;
134
+ const resultThreads = filteredThreads.slice(0, limit);
135
+
136
+ // Get first message preview and count for each thread
137
+ const threadsWithPreviews = await Promise.all(
138
+ resultThreads.map(async (thread) => {
139
+ const messages = await ctx.db
140
+ .query("messages")
141
+ .withIndex("threadId_createdAt", (q) => q.eq("threadId", thread._id))
142
+ .order("asc")
143
+ .take(1);
144
+
145
+ const firstMessage = messages[0] ?? null;
146
+
147
+ // Get message count
148
+ const allMessages = await ctx.db
149
+ .query("messages")
150
+ .withIndex("threadId", (q) => q.eq("threadId", thread._id))
151
+ .collect();
152
+
153
+ const messageCount = allMessages.filter((m) => !m.isDeleted).length;
154
+
155
+ return {
156
+ thread,
157
+ firstMessage: firstMessage
158
+ ? {
159
+ _id: firstMessage._id,
160
+ body: firstMessage.body,
161
+ authorId: firstMessage.authorId,
162
+ createdAt: firstMessage.createdAt,
163
+ }
164
+ : null,
165
+ messageCount,
166
+ };
167
+ })
168
+ );
169
+
170
+ // Generate next cursor
171
+ const lastThread = resultThreads[resultThreads.length - 1];
172
+ const nextCursor = hasMore && lastThread
173
+ ? lastThread.lastActivityAt.toString()
174
+ : undefined;
175
+
176
+ return {
177
+ threads: threadsWithPreviews,
178
+ nextCursor,
179
+ hasMore,
180
+ };
181
+ },
182
+ });
183
+
184
+ /**
185
+ * Resolve a thread.
186
+ */
187
+ export const resolveThread = mutation({
188
+ args: {
189
+ threadId: v.id("threads"),
190
+ userId: v.string(),
191
+ },
192
+ returns: v.null(),
193
+ handler: async (ctx, args) => {
194
+ const thread = await ctx.db.get(args.threadId);
195
+ if (!thread) {
196
+ throw new Error(`Thread ${args.threadId} not found`);
197
+ }
198
+
199
+ await ctx.db.patch(args.threadId, {
200
+ resolved: true,
201
+ resolvedBy: args.userId,
202
+ resolvedAt: Date.now(),
203
+ });
204
+
205
+ return null;
206
+ },
207
+ });
208
+
209
+ /**
210
+ * Unresolve a thread.
211
+ */
212
+ export const unresolveThread = mutation({
213
+ args: {
214
+ threadId: v.id("threads"),
215
+ },
216
+ returns: v.null(),
217
+ handler: async (ctx, args) => {
218
+ const thread = await ctx.db.get(args.threadId);
219
+ if (!thread) {
220
+ throw new Error(`Thread ${args.threadId} not found`);
221
+ }
222
+
223
+ await ctx.db.patch(args.threadId, {
224
+ resolved: false,
225
+ resolvedBy: undefined,
226
+ resolvedAt: undefined,
227
+ });
228
+
229
+ return null;
230
+ },
231
+ });
232
+
233
+ /**
234
+ * Update thread position (for positioned comments).
235
+ */
236
+ export const updateThreadPosition = mutation({
237
+ args: {
238
+ threadId: v.id("threads"),
239
+ position: v.optional(positionValidator),
240
+ },
241
+ returns: v.null(),
242
+ handler: async (ctx, args) => {
243
+ await ctx.db.patch(args.threadId, {
244
+ position: args.position,
245
+ });
246
+ return null;
247
+ },
248
+ });
249
+
250
+ /**
251
+ * Delete a thread and all its messages.
252
+ */
253
+ export const deleteThread = mutation({
254
+ args: {
255
+ threadId: v.id("threads"),
256
+ },
257
+ returns: v.object({
258
+ deletedMessages: v.number(),
259
+ deletedReactions: v.number(),
260
+ }),
261
+ handler: async (ctx, args) => {
262
+ let deletedMessages = 0;
263
+ let deletedReactions = 0;
264
+
265
+ // Get all messages in this thread
266
+ const messages = await ctx.db
267
+ .query("messages")
268
+ .withIndex("threadId", (q) => q.eq("threadId", args.threadId))
269
+ .collect();
270
+
271
+ for (const message of messages) {
272
+ // Delete reactions on this message
273
+ const reactions = await ctx.db
274
+ .query("reactions")
275
+ .withIndex("messageId", (q) => q.eq("messageId", message._id))
276
+ .collect();
277
+
278
+ for (const reaction of reactions) {
279
+ await ctx.db.delete(reaction._id);
280
+ deletedReactions++;
281
+ }
282
+
283
+ // Delete the message
284
+ await ctx.db.delete(message._id);
285
+ deletedMessages++;
286
+ }
287
+
288
+ // Delete typing indicators
289
+ const typingIndicators = await ctx.db
290
+ .query("typingIndicators")
291
+ .withIndex("threadId", (q) => q.eq("threadId", args.threadId))
292
+ .collect();
293
+
294
+ for (const indicator of typingIndicators) {
295
+ await ctx.db.delete(indicator._id);
296
+ }
297
+
298
+ // Delete the thread
299
+ await ctx.db.delete(args.threadId);
300
+
301
+ return { deletedMessages, deletedReactions };
302
+ },
303
+ });
304
+
305
+ /**
306
+ * Internal function to update thread's lastActivityAt.
307
+ */
308
+ export const updateThreadActivity = mutation({
309
+ args: {
310
+ threadId: v.id("threads"),
311
+ },
312
+ returns: v.null(),
313
+ handler: async (ctx, args) => {
314
+ await ctx.db.patch(args.threadId, {
315
+ lastActivityAt: Date.now(),
316
+ });
317
+ return null;
318
+ },
319
+ });
@@ -0,0 +1,168 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query, internalMutation } from "./_generated/server.js";
3
+ import { internal } from "./_generated/api.js";
4
+
5
+ // ============================================================================
6
+ // Typing Indicator Configuration
7
+ // ============================================================================
8
+
9
+ /** How long (in ms) before a typing indicator expires */
10
+ const TYPING_EXPIRY_MS = 5000; // 5 seconds
11
+
12
+ // ============================================================================
13
+ // Typing Indicator Validators
14
+ // ============================================================================
15
+
16
+ const typingUserValidator = v.object({
17
+ userId: v.string(),
18
+ updatedAt: v.number(),
19
+ });
20
+
21
+ // ============================================================================
22
+ // Typing Indicator Functions
23
+ // ============================================================================
24
+
25
+ /**
26
+ * Set typing indicator for a user in a thread.
27
+ * Automatically schedules expiry cleanup.
28
+ */
29
+ export const setIsTyping = mutation({
30
+ args: {
31
+ threadId: v.id("threads"),
32
+ userId: v.string(),
33
+ isTyping: v.boolean(),
34
+ },
35
+ returns: v.null(),
36
+ handler: async (ctx, args) => {
37
+ // Verify thread exists
38
+ const thread = await ctx.db.get(args.threadId);
39
+ if (!thread) {
40
+ throw new Error(`Thread ${args.threadId} not found`);
41
+ }
42
+
43
+ const now = Date.now();
44
+ const expiresAt = now + TYPING_EXPIRY_MS;
45
+
46
+ // Find existing indicator
47
+ const existing = await ctx.db
48
+ .query("typingIndicators")
49
+ .withIndex("threadId_userId", (q) =>
50
+ q.eq("threadId", args.threadId).eq("userId", args.userId)
51
+ )
52
+ .first();
53
+
54
+ if (args.isTyping) {
55
+ if (existing) {
56
+ // Update existing indicator
57
+ await ctx.db.patch(existing._id, {
58
+ updatedAt: now,
59
+ expiresAt,
60
+ });
61
+ } else {
62
+ // Create new indicator
63
+ await ctx.db.insert("typingIndicators", {
64
+ threadId: args.threadId,
65
+ userId: args.userId,
66
+ updatedAt: now,
67
+ expiresAt,
68
+ });
69
+ }
70
+
71
+ // Schedule cleanup
72
+ await ctx.scheduler.runAfter(
73
+ TYPING_EXPIRY_MS + 1000, // Add a small buffer
74
+ internal.typing.cleanupExpiredIndicators,
75
+ {}
76
+ );
77
+ } else {
78
+ // Remove indicator if exists
79
+ if (existing) {
80
+ await ctx.db.delete(existing._id);
81
+ }
82
+ }
83
+
84
+ return null;
85
+ },
86
+ });
87
+
88
+ /**
89
+ * Get all users currently typing in a thread.
90
+ */
91
+ export const getTypingUsers = query({
92
+ args: {
93
+ threadId: v.id("threads"),
94
+ excludeUserId: v.optional(v.string()),
95
+ },
96
+ returns: v.array(typingUserValidator),
97
+ handler: async (ctx, args) => {
98
+ const now = Date.now();
99
+
100
+ const indicators = await ctx.db
101
+ .query("typingIndicators")
102
+ .withIndex("threadId", (q) => q.eq("threadId", args.threadId))
103
+ .collect();
104
+
105
+ // Filter to only non-expired indicators and optionally exclude current user
106
+ return indicators
107
+ .filter((i) => {
108
+ if (i.expiresAt < now) return false;
109
+ if (args.excludeUserId && i.userId === args.excludeUserId) return false;
110
+ return true;
111
+ })
112
+ .map((i) => ({
113
+ userId: i.userId,
114
+ updatedAt: i.updatedAt,
115
+ }));
116
+ },
117
+ });
118
+
119
+ /**
120
+ * Internal function to cleanup expired typing indicators.
121
+ */
122
+ export const cleanupExpiredIndicators = internalMutation({
123
+ args: {},
124
+ returns: v.number(),
125
+ handler: async (ctx) => {
126
+ const now = Date.now();
127
+ let deleted = 0;
128
+
129
+ // Get all expired indicators
130
+ const expired = await ctx.db
131
+ .query("typingIndicators")
132
+ .withIndex("expiresAt", (q) => q.lt("expiresAt", now))
133
+ .collect();
134
+
135
+ for (const indicator of expired) {
136
+ await ctx.db.delete(indicator._id);
137
+ deleted++;
138
+ }
139
+
140
+ return deleted;
141
+ },
142
+ });
143
+
144
+ /**
145
+ * Clear all typing indicators for a user across all threads.
146
+ * Useful when a user disconnects.
147
+ */
148
+ export const clearUserTyping = mutation({
149
+ args: {
150
+ userId: v.string(),
151
+ },
152
+ returns: v.number(),
153
+ handler: async (ctx, args) => {
154
+ // We need to scan all indicators since we don't have a userId index
155
+ // In production, you might want to add an index on userId
156
+ const allIndicators = await ctx.db.query("typingIndicators").collect();
157
+ let deleted = 0;
158
+
159
+ for (const indicator of allIndicators) {
160
+ if (indicator.userId === args.userId) {
161
+ await ctx.db.delete(indicator._id);
162
+ deleted++;
163
+ }
164
+ }
165
+
166
+ return deleted;
167
+ },
168
+ });