@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.
- package/CHANGELOG.md +17 -0
- package/LICENSE +201 -0
- package/README.md +581 -0
- package/dist/client/_generated/_ignore.d.ts +1 -0
- package/dist/client/_generated/_ignore.d.ts.map +1 -0
- package/dist/client/_generated/_ignore.js +3 -0
- package/dist/client/_generated/_ignore.js.map +1 -0
- package/dist/client/index.d.ts +745 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +579 -0
- package/dist/client/index.js.map +1 -0
- package/dist/component/_generated/api.d.ts +44 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +673 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +3 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/lib.d.ts +17 -0
- package/dist/component/lib.d.ts.map +1 -0
- package/dist/component/lib.js +18 -0
- package/dist/component/lib.js.map +1 -0
- package/dist/component/messages.d.ts +173 -0
- package/dist/component/messages.d.ts.map +1 -0
- package/dist/component/messages.js +410 -0
- package/dist/component/messages.js.map +1 -0
- package/dist/component/reactions.d.ts +51 -0
- package/dist/component/reactions.d.ts.map +1 -0
- package/dist/component/reactions.js +191 -0
- package/dist/component/reactions.js.map +1 -0
- package/dist/component/schema.d.ts +274 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +159 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/threads.d.ts +110 -0
- package/dist/component/threads.d.ts.map +1 -0
- package/dist/component/threads.js +276 -0
- package/dist/component/threads.js.map +1 -0
- package/dist/component/typing.d.ts +31 -0
- package/dist/component/typing.d.ts.map +1 -0
- package/dist/component/typing.js +147 -0
- package/dist/component/typing.js.map +1 -0
- package/dist/component/zones.d.ts +63 -0
- package/dist/component/zones.d.ts.map +1 -0
- package/dist/component/zones.js +159 -0
- package/dist/component/zones.js.map +1 -0
- package/dist/react/AddComment.d.ts +57 -0
- package/dist/react/AddComment.d.ts.map +1 -0
- package/dist/react/AddComment.js +285 -0
- package/dist/react/AddComment.js.map +1 -0
- package/dist/react/Comment.d.ts +70 -0
- package/dist/react/Comment.d.ts.map +1 -0
- package/dist/react/Comment.js +259 -0
- package/dist/react/Comment.js.map +1 -0
- package/dist/react/Comments.d.ts +74 -0
- package/dist/react/Comments.d.ts.map +1 -0
- package/dist/react/Comments.js +108 -0
- package/dist/react/Comments.js.map +1 -0
- package/dist/react/CommentsProvider.d.ts +104 -0
- package/dist/react/CommentsProvider.d.ts.map +1 -0
- package/dist/react/CommentsProvider.js +98 -0
- package/dist/react/CommentsProvider.js.map +1 -0
- package/dist/react/ReactionPicker.d.ts +28 -0
- package/dist/react/ReactionPicker.d.ts.map +1 -0
- package/dist/react/ReactionPicker.js +56 -0
- package/dist/react/ReactionPicker.js.map +1 -0
- package/dist/react/Thread.d.ts +84 -0
- package/dist/react/Thread.d.ts.map +1 -0
- package/dist/react/Thread.js +124 -0
- package/dist/react/Thread.js.map +1 -0
- package/dist/react/TypingIndicator.d.ts +25 -0
- package/dist/react/TypingIndicator.d.ts.map +1 -0
- package/dist/react/TypingIndicator.js +99 -0
- package/dist/react/TypingIndicator.js.map +1 -0
- package/dist/react/index.d.ts +15 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +15 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +106 -0
- package/src/client/_generated/_ignore.ts +1 -0
- package/src/client/index.ts +813 -0
- package/src/component/_generated/api.ts +60 -0
- package/src/component/_generated/component.ts +784 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +3 -0
- package/src/component/lib.ts +57 -0
- package/src/component/messages.ts +476 -0
- package/src/component/reactions.ts +222 -0
- package/src/component/schema.ts +169 -0
- package/src/component/threads.ts +319 -0
- package/src/component/typing.ts +168 -0
- package/src/component/zones.ts +180 -0
- package/src/react/AddComment.tsx +463 -0
- package/src/react/Comment.tsx +519 -0
- package/src/react/Comments.tsx +276 -0
- package/src/react/CommentsProvider.tsx +197 -0
- package/src/react/ReactionPicker.tsx +95 -0
- package/src/react/Thread.tsx +336 -0
- package/src/react/TypingIndicator.tsx +144 -0
- 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
|
+
});
|