@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,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
|
+
}
|