@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,180 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+
4
+ // ============================================================================
5
+ // Zone Validators
6
+ // ============================================================================
7
+
8
+ const zoneValidator = v.object({
9
+ _id: v.id("zones"),
10
+ _creationTime: v.number(),
11
+ entityId: v.string(),
12
+ metadata: v.optional(v.any()),
13
+ createdAt: v.number(),
14
+ });
15
+
16
+ // ============================================================================
17
+ // Zone Functions
18
+ // ============================================================================
19
+
20
+ /**
21
+ * Get or create a zone for an entity.
22
+ * This is the main entry point - lazily creates a zone if it doesn't exist.
23
+ */
24
+ export const getOrCreateZone = mutation({
25
+ args: {
26
+ entityId: v.string(),
27
+ metadata: v.optional(v.any()),
28
+ },
29
+ returns: v.id("zones"),
30
+ handler: async (ctx, args) => {
31
+ // Check if zone already exists
32
+ const existing = await ctx.db
33
+ .query("zones")
34
+ .withIndex("entityId", (q) => q.eq("entityId", args.entityId))
35
+ .first();
36
+
37
+ if (existing) {
38
+ return existing._id;
39
+ }
40
+
41
+ // Create new zone
42
+ const zoneId = await ctx.db.insert("zones", {
43
+ entityId: args.entityId,
44
+ metadata: args.metadata,
45
+ createdAt: Date.now(),
46
+ });
47
+
48
+ return zoneId;
49
+ },
50
+ });
51
+
52
+ /**
53
+ * List all zones with optional pagination.
54
+ */
55
+ export const listZones = query({
56
+ args: {
57
+ limit: v.optional(v.number()),
58
+ },
59
+ returns: v.array(zoneValidator),
60
+ handler: async (ctx, args) => {
61
+ const limit = args.limit ?? 100;
62
+ return await ctx.db.query("zones").take(limit);
63
+ },
64
+ });
65
+
66
+ /**
67
+ * Get a zone by entity ID (without creating).
68
+ */
69
+ export const getZone = query({
70
+ args: {
71
+ entityId: v.string(),
72
+ },
73
+ returns: v.union(v.null(), zoneValidator),
74
+ handler: async (ctx, args) => {
75
+ return await ctx.db
76
+ .query("zones")
77
+ .withIndex("entityId", (q) => q.eq("entityId", args.entityId))
78
+ .first();
79
+ },
80
+ });
81
+
82
+ /**
83
+ * Get a zone by its ID.
84
+ */
85
+ export const getZoneById = query({
86
+ args: {
87
+ zoneId: v.id("zones"),
88
+ },
89
+ returns: v.union(v.null(), zoneValidator),
90
+ handler: async (ctx, args) => {
91
+ return await ctx.db.get(args.zoneId);
92
+ },
93
+ });
94
+
95
+ /**
96
+ * Update zone metadata.
97
+ */
98
+ export const updateZoneMetadata = mutation({
99
+ args: {
100
+ zoneId: v.id("zones"),
101
+ metadata: v.any(),
102
+ },
103
+ returns: v.null(),
104
+ handler: async (ctx, args) => {
105
+ await ctx.db.patch(args.zoneId, {
106
+ metadata: args.metadata,
107
+ });
108
+ return null;
109
+ },
110
+ });
111
+
112
+ /**
113
+ * Delete a zone and all its threads/messages.
114
+ * Use with caution - this is a destructive operation.
115
+ */
116
+ export const deleteZone = mutation({
117
+ args: {
118
+ zoneId: v.id("zones"),
119
+ },
120
+ returns: v.object({
121
+ deletedThreads: v.number(),
122
+ deletedMessages: v.number(),
123
+ deletedReactions: v.number(),
124
+ }),
125
+ handler: async (ctx, args) => {
126
+ let deletedThreads = 0;
127
+ let deletedMessages = 0;
128
+ let deletedReactions = 0;
129
+
130
+ // Get all threads in this zone
131
+ const threads = await ctx.db
132
+ .query("threads")
133
+ .withIndex("zoneId", (q) => q.eq("zoneId", args.zoneId))
134
+ .collect();
135
+
136
+ for (const thread of threads) {
137
+ // Get all messages in this thread
138
+ const messages = await ctx.db
139
+ .query("messages")
140
+ .withIndex("threadId", (q) => q.eq("threadId", thread._id))
141
+ .collect();
142
+
143
+ for (const message of messages) {
144
+ // Delete reactions on this message
145
+ const reactions = await ctx.db
146
+ .query("reactions")
147
+ .withIndex("messageId", (q) => q.eq("messageId", message._id))
148
+ .collect();
149
+
150
+ for (const reaction of reactions) {
151
+ await ctx.db.delete(reaction._id);
152
+ deletedReactions++;
153
+ }
154
+
155
+ // Delete the message
156
+ await ctx.db.delete(message._id);
157
+ deletedMessages++;
158
+ }
159
+
160
+ // Delete typing indicators for this thread
161
+ const typingIndicators = await ctx.db
162
+ .query("typingIndicators")
163
+ .withIndex("threadId", (q) => q.eq("threadId", thread._id))
164
+ .collect();
165
+
166
+ for (const indicator of typingIndicators) {
167
+ await ctx.db.delete(indicator._id);
168
+ }
169
+
170
+ // Delete the thread
171
+ await ctx.db.delete(thread._id);
172
+ deletedThreads++;
173
+ }
174
+
175
+ // Delete the zone
176
+ await ctx.db.delete(args.zoneId);
177
+
178
+ return { deletedThreads, deletedMessages, deletedReactions };
179
+ },
180
+ });
@@ -0,0 +1,463 @@
1
+ /**
2
+ * AddComment Component
3
+ *
4
+ * Composer for adding new comments with mention autocomplete.
5
+ */
6
+
7
+ import { useState, useRef, useCallback, useEffect } from "react";
8
+ import { useComments } from "./CommentsProvider";
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ export interface MentionableUser {
15
+ /** User ID */
16
+ id: string;
17
+ /** Display name */
18
+ name: string;
19
+ /** Optional avatar URL */
20
+ avatar?: string;
21
+ }
22
+
23
+ export interface AddCommentProps {
24
+ /** Callback when a comment is submitted */
25
+ onSubmit?: (
26
+ body: string,
27
+ attachments?: Array<{
28
+ type: "url" | "file" | "image";
29
+ url: string;
30
+ name?: string;
31
+ }>
32
+ ) => void;
33
+ /** Callback when typing state changes */
34
+ onTypingChange?: (isTyping: boolean) => void;
35
+ /** List of users that can be mentioned */
36
+ mentionableUsers?: MentionableUser[];
37
+ /** Whether editing features are enabled */
38
+ allowEditing?: boolean;
39
+ /** Placeholder text */
40
+ placeholder?: string;
41
+ /** Initial value */
42
+ initialValue?: string;
43
+ /** Whether the composer is disabled */
44
+ disabled?: boolean;
45
+ /** Auto-focus on mount */
46
+ autoFocus?: boolean;
47
+ /** Minimum height of the textarea */
48
+ minHeight?: number;
49
+ /** CSS class name */
50
+ className?: string;
51
+ }
52
+
53
+ // ============================================================================
54
+ // Component
55
+ // ============================================================================
56
+
57
+ /**
58
+ * Comment composer with mention autocomplete support.
59
+ *
60
+ * Usage:
61
+ * ```tsx
62
+ * <AddComment
63
+ * onSubmit={(body) => addComment({ threadId, body })}
64
+ * onTypingChange={(isTyping) => setIsTyping({ threadId, isTyping })}
65
+ * mentionableUsers={[
66
+ * { id: "user1", name: "Alice" },
67
+ * { id: "user2", name: "Bob" },
68
+ * ]}
69
+ * placeholder="Write a comment..."
70
+ * />
71
+ * ```
72
+ */
73
+ export function AddComment({
74
+ onSubmit,
75
+ onTypingChange,
76
+ mentionableUsers = [],
77
+ placeholder = "Write a comment...",
78
+ initialValue = "",
79
+ disabled = false,
80
+ autoFocus = false,
81
+ minHeight = 60,
82
+ className = "",
83
+ }: AddCommentProps) {
84
+ const { userId, styles } = useComments();
85
+ const [body, setBody] = useState(initialValue);
86
+ const [isTyping, setIsTyping] = useState(false);
87
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
88
+ const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
89
+
90
+ // Mention autocomplete state
91
+ const [showMentionPicker, setShowMentionPicker] = useState(false);
92
+ const [mentionQuery, setMentionQuery] = useState("");
93
+ const [mentionStartPos, setMentionStartPos] = useState(0);
94
+ const [selectedMentionIndex, setSelectedMentionIndex] = useState(0);
95
+ const mentionPickerRef = useRef<HTMLDivElement>(null);
96
+
97
+ // Filter mentionable users based on query
98
+ const filteredUsers = mentionableUsers.filter((user) =>
99
+ user.name.toLowerCase().includes(mentionQuery.toLowerCase()) ||
100
+ user.id.toLowerCase().includes(mentionQuery.toLowerCase())
101
+ );
102
+
103
+ // Debounced typing indicator
104
+ const updateTypingState = useCallback(
105
+ (typing: boolean) => {
106
+ if (typing !== isTyping) {
107
+ setIsTyping(typing);
108
+ onTypingChange?.(typing);
109
+ }
110
+ },
111
+ [isTyping, onTypingChange]
112
+ );
113
+
114
+ const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
115
+ const value = e.target.value;
116
+ const cursorPos = e.target.selectionStart;
117
+ setBody(value);
118
+
119
+ // Check for mention trigger
120
+ if (mentionableUsers.length > 0) {
121
+ // Find the last @ before cursor that's not already complete
122
+ const textBeforeCursor = value.slice(0, cursorPos);
123
+ const lastAtIndex = textBeforeCursor.lastIndexOf("@");
124
+
125
+ if (lastAtIndex >= 0) {
126
+ const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1);
127
+ // Check if there's no space after @ (still typing the mention)
128
+ if (!textAfterAt.includes(" ") && !textAfterAt.includes("\n")) {
129
+ setShowMentionPicker(true);
130
+ setMentionQuery(textAfterAt);
131
+ setMentionStartPos(lastAtIndex);
132
+ setSelectedMentionIndex(0);
133
+ } else {
134
+ setShowMentionPicker(false);
135
+ }
136
+ } else {
137
+ setShowMentionPicker(false);
138
+ }
139
+ }
140
+
141
+ // Start typing indicator
142
+ if (value.length > 0) {
143
+ updateTypingState(true);
144
+
145
+ // Clear existing timeout
146
+ if (typingTimeoutRef.current) {
147
+ clearTimeout(typingTimeoutRef.current);
148
+ }
149
+
150
+ // Set new timeout to stop typing
151
+ typingTimeoutRef.current = setTimeout(() => {
152
+ updateTypingState(false);
153
+ }, 2000);
154
+ } else {
155
+ updateTypingState(false);
156
+ }
157
+ };
158
+
159
+ const insertMention = (user: MentionableUser) => {
160
+ const beforeMention = body.slice(0, mentionStartPos);
161
+ const afterMention = body.slice(
162
+ mentionStartPos + 1 + mentionQuery.length
163
+ );
164
+ const newBody = `${beforeMention}@${user.id} ${afterMention}`;
165
+ setBody(newBody);
166
+ setShowMentionPicker(false);
167
+ setMentionQuery("");
168
+
169
+ // Focus textarea and set cursor position
170
+ if (textareaRef.current) {
171
+ const newCursorPos = mentionStartPos + user.id.length + 2; // +2 for @ and space
172
+ textareaRef.current.focus();
173
+ setTimeout(() => {
174
+ textareaRef.current?.setSelectionRange(newCursorPos, newCursorPos);
175
+ }, 0);
176
+ }
177
+ };
178
+
179
+ const handleSubmit = (e: React.FormEvent) => {
180
+ e.preventDefault();
181
+ if (!body.trim() || disabled) return;
182
+
183
+ // Clear typing indicator
184
+ updateTypingState(false);
185
+ if (typingTimeoutRef.current) {
186
+ clearTimeout(typingTimeoutRef.current);
187
+ }
188
+
189
+ onSubmit?.(body.trim());
190
+ setBody("");
191
+ setShowMentionPicker(false);
192
+
193
+ // Reset textarea height
194
+ if (textareaRef.current) {
195
+ textareaRef.current.style.height = `${minHeight}px`;
196
+ }
197
+ };
198
+
199
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
200
+ // Handle mention picker navigation
201
+ if (showMentionPicker && filteredUsers.length > 0) {
202
+ if (e.key === "ArrowDown") {
203
+ e.preventDefault();
204
+ setSelectedMentionIndex((prev) =>
205
+ prev < filteredUsers.length - 1 ? prev + 1 : prev
206
+ );
207
+ return;
208
+ }
209
+ if (e.key === "ArrowUp") {
210
+ e.preventDefault();
211
+ setSelectedMentionIndex((prev) => (prev > 0 ? prev - 1 : prev));
212
+ return;
213
+ }
214
+ if (e.key === "Enter" || e.key === "Tab") {
215
+ e.preventDefault();
216
+ insertMention(filteredUsers[selectedMentionIndex]);
217
+ return;
218
+ }
219
+ if (e.key === "Escape") {
220
+ e.preventDefault();
221
+ setShowMentionPicker(false);
222
+ return;
223
+ }
224
+ }
225
+
226
+ // Submit on Enter (without Shift)
227
+ if (e.key === "Enter" && !e.shiftKey) {
228
+ e.preventDefault();
229
+ handleSubmit(e);
230
+ }
231
+ };
232
+
233
+ // Auto-resize textarea
234
+ useEffect(() => {
235
+ if (textareaRef.current) {
236
+ textareaRef.current.style.height = `${minHeight}px`;
237
+ textareaRef.current.style.height = `${Math.max(
238
+ minHeight,
239
+ textareaRef.current.scrollHeight
240
+ )}px`;
241
+ }
242
+ }, [body, minHeight]);
243
+
244
+ // Cleanup typing timeout on unmount
245
+ useEffect(() => {
246
+ return () => {
247
+ if (typingTimeoutRef.current) {
248
+ clearTimeout(typingTimeoutRef.current);
249
+ }
250
+ if (isTyping) {
251
+ onTypingChange?.(false);
252
+ }
253
+ };
254
+ }, [isTyping, onTypingChange]);
255
+
256
+ // Close mention picker on click outside
257
+ useEffect(() => {
258
+ const handleClickOutside = (e: MouseEvent) => {
259
+ if (
260
+ mentionPickerRef.current &&
261
+ !mentionPickerRef.current.contains(e.target as Node) &&
262
+ textareaRef.current &&
263
+ !textareaRef.current.contains(e.target as Node)
264
+ ) {
265
+ setShowMentionPicker(false);
266
+ }
267
+ };
268
+ document.addEventListener("mousedown", handleClickOutside);
269
+ return () => document.removeEventListener("mousedown", handleClickOutside);
270
+ }, []);
271
+
272
+ if (!userId) {
273
+ return (
274
+ <div
275
+ className={className}
276
+ style={{
277
+ padding: "12px 16px",
278
+ textAlign: "center",
279
+ color: "#6b7280",
280
+ fontSize: "13px",
281
+ }}
282
+ >
283
+ Sign in to comment
284
+ </div>
285
+ );
286
+ }
287
+
288
+ return (
289
+ <form
290
+ onSubmit={handleSubmit}
291
+ className={`add-comment ${className}`}
292
+ style={{
293
+ padding: "12px",
294
+ }}
295
+ >
296
+ <div style={{ position: "relative" }}>
297
+ <textarea
298
+ ref={textareaRef}
299
+ value={body}
300
+ onChange={handleChange}
301
+ onKeyDown={handleKeyDown}
302
+ placeholder={placeholder}
303
+ disabled={disabled}
304
+ autoFocus={autoFocus}
305
+ style={{
306
+ width: "100%",
307
+ padding: "10px 12px",
308
+ paddingRight: "80px",
309
+ border: "1px solid #d1d5db",
310
+ borderRadius: styles?.borderRadius ?? "8px",
311
+ fontSize: "14px",
312
+ fontFamily: styles?.fontFamily ?? "inherit",
313
+ resize: "none",
314
+ minHeight: `${minHeight}px`,
315
+ outline: "none",
316
+ transition: "border-color 0.15s",
317
+ }}
318
+ onFocus={(e) => {
319
+ e.target.style.borderColor = styles?.accentColor ?? "#3b82f6";
320
+ }}
321
+ onBlur={(e) => {
322
+ e.target.style.borderColor = "#d1d5db";
323
+ }}
324
+ />
325
+
326
+ {/* Mention Autocomplete Dropdown */}
327
+ {showMentionPicker && filteredUsers.length > 0 && (
328
+ <div
329
+ ref={mentionPickerRef}
330
+ style={{
331
+ position: "absolute",
332
+ bottom: "100%",
333
+ left: "0",
334
+ right: "0",
335
+ marginBottom: "4px",
336
+ maxHeight: "200px",
337
+ overflowY: "auto",
338
+ backgroundColor: "#fff",
339
+ border: "1px solid #d1d5db",
340
+ borderRadius: "8px",
341
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
342
+ zIndex: 100,
343
+ }}
344
+ >
345
+ {filteredUsers.map((user, index) => (
346
+ <div
347
+ key={user.id}
348
+ onClick={() => insertMention(user)}
349
+ style={{
350
+ padding: "8px 12px",
351
+ cursor: "pointer",
352
+ display: "flex",
353
+ alignItems: "center",
354
+ gap: "8px",
355
+ backgroundColor:
356
+ index === selectedMentionIndex
357
+ ? "#f3f4f6"
358
+ : "transparent",
359
+ }}
360
+ onMouseEnter={() => setSelectedMentionIndex(index)}
361
+ >
362
+ {user.avatar ? (
363
+ <img
364
+ src={user.avatar}
365
+ alt={user.name}
366
+ style={{
367
+ width: "24px",
368
+ height: "24px",
369
+ borderRadius: "50%",
370
+ objectFit: "cover",
371
+ }}
372
+ />
373
+ ) : (
374
+ <div
375
+ style={{
376
+ width: "24px",
377
+ height: "24px",
378
+ borderRadius: "50%",
379
+ backgroundColor: "#e5e7eb",
380
+ display: "flex",
381
+ alignItems: "center",
382
+ justifyContent: "center",
383
+ fontSize: "12px",
384
+ fontWeight: 500,
385
+ color: "#6b7280",
386
+ }}
387
+ >
388
+ {user.name.charAt(0).toUpperCase()}
389
+ </div>
390
+ )}
391
+ <div>
392
+ <div style={{ fontWeight: 500, fontSize: "13px" }}>
393
+ {user.name}
394
+ </div>
395
+ <div style={{ fontSize: "11px", color: "#9ca3af" }}>
396
+ @{user.id}
397
+ </div>
398
+ </div>
399
+ </div>
400
+ ))}
401
+ </div>
402
+ )}
403
+
404
+ {/* No matches message */}
405
+ {showMentionPicker && filteredUsers.length === 0 && mentionQuery.length > 0 && (
406
+ <div
407
+ style={{
408
+ position: "absolute",
409
+ bottom: "100%",
410
+ left: "0",
411
+ right: "0",
412
+ marginBottom: "4px",
413
+ padding: "12px",
414
+ backgroundColor: "#fff",
415
+ border: "1px solid #d1d5db",
416
+ borderRadius: "8px",
417
+ boxShadow: "0 4px 12px rgba(0, 0, 0, 0.15)",
418
+ color: "#9ca3af",
419
+ fontSize: "13px",
420
+ textAlign: "center",
421
+ }}
422
+ >
423
+ No users found matching "{mentionQuery}"
424
+ </div>
425
+ )}
426
+
427
+ <button
428
+ type="submit"
429
+ disabled={!body.trim() || disabled}
430
+ style={{
431
+ position: "absolute",
432
+ right: "8px",
433
+ bottom: "8px",
434
+ padding: "6px 14px",
435
+ fontSize: "13px",
436
+ fontWeight: 500,
437
+ border: "none",
438
+ borderRadius: "6px",
439
+ backgroundColor:
440
+ !body.trim() || disabled
441
+ ? "#e5e7eb"
442
+ : styles?.accentColor ?? "#3b82f6",
443
+ color: !body.trim() || disabled ? "#9ca3af" : "white",
444
+ cursor: !body.trim() || disabled ? "not-allowed" : "pointer",
445
+ transition: "background-color 0.15s",
446
+ }}
447
+ >
448
+ Send
449
+ </button>
450
+ </div>
451
+ <div
452
+ style={{
453
+ marginTop: "6px",
454
+ fontSize: "11px",
455
+ color: "#9ca3af",
456
+ }}
457
+ >
458
+ Press Enter to send, Shift+Enter for new line
459
+ {mentionableUsers.length > 0 && " • Type @ to mention"}
460
+ </div>
461
+ </form>
462
+ );
463
+ }