@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,336 @@
1
+ /**
2
+ * Thread Component
3
+ *
4
+ * Displays a single thread with all its messages.
5
+ */
6
+
7
+ import { useEffect, useRef, type ReactNode } from "react";
8
+ import { useComments } from "./CommentsProvider";
9
+ import { Comment, type MessageData } from "./Comment";
10
+ import { AddComment } from "./AddComment";
11
+ import { TypingIndicator } from "./TypingIndicator";
12
+
13
+ // ============================================================================
14
+ // Types
15
+ // ============================================================================
16
+
17
+ export interface ThreadInfo {
18
+ _id: string;
19
+ zoneId: string;
20
+ resolved: boolean;
21
+ resolvedBy?: string;
22
+ resolvedAt?: number;
23
+ createdAt: number;
24
+ lastActivityAt: number;
25
+ position?: { x: number; y: number; anchor?: string };
26
+ metadata?: unknown;
27
+ }
28
+
29
+ export interface ThreadProps {
30
+ /** Thread data */
31
+ thread: ThreadInfo;
32
+ /** Messages in this thread */
33
+ messages: MessageData[];
34
+ /** Typing users in this thread */
35
+ typingUsers?: Array<{ userId: string; updatedAt: number }>;
36
+ /** Whether there are more messages to load */
37
+ hasMore?: boolean;
38
+ /** Loading state */
39
+ isLoading?: boolean;
40
+ /** Callback when load more is triggered */
41
+ onLoadMore?: () => void;
42
+ /** Callback when a new message is submitted */
43
+ onSubmit?: (body: string, attachments?: Array<{ type: "url" | "file" | "image"; url: string; name?: string }>) => void;
44
+ /** Callback when typing state changes */
45
+ onTypingChange?: (isTyping: boolean) => void;
46
+ /** Callback when a reaction is toggled */
47
+ onToggleReaction?: (messageId: string, emoji: string) => void;
48
+ /** Callback when a message is edited */
49
+ onEditMessage?: (messageId: string, newBody: string) => void;
50
+ /** Callback when a message is deleted */
51
+ onDeleteMessage?: (messageId: string) => void;
52
+ /** Callback to resolve the thread */
53
+ onResolve?: () => void;
54
+ /** Callback to unresolve the thread */
55
+ onUnresolve?: () => void;
56
+ /** Callback to close/go back */
57
+ onClose?: () => void;
58
+ /** Whether editing is allowed */
59
+ allowEditing?: boolean;
60
+ /** Whether to auto-scroll to bottom on new messages */
61
+ autoScroll?: boolean;
62
+ /** Custom message renderer */
63
+ renderMessage?: (message: MessageData, mine: boolean) => ReactNode;
64
+ /** CSS class name */
65
+ className?: string;
66
+ }
67
+
68
+ // ============================================================================
69
+ // Component
70
+ // ============================================================================
71
+
72
+ /**
73
+ * Component to display a single thread with its messages.
74
+ *
75
+ * Usage:
76
+ * ```tsx
77
+ * <Thread
78
+ * thread={thread}
79
+ * messages={messages}
80
+ * typingUsers={typingUsers}
81
+ * onSubmit={(body) => addComment({ threadId: thread._id, body })}
82
+ * onTypingChange={(isTyping) => setIsTyping({ threadId: thread._id, isTyping })}
83
+ * onToggleReaction={(messageId, emoji) => toggleReaction({ messageId, emoji })}
84
+ * />
85
+ * ```
86
+ */
87
+ export function Thread({
88
+ thread,
89
+ messages,
90
+ typingUsers = [],
91
+ hasMore = false,
92
+ isLoading = false,
93
+ onLoadMore,
94
+ onSubmit,
95
+ onTypingChange,
96
+ onToggleReaction,
97
+ onEditMessage,
98
+ onDeleteMessage,
99
+ onResolve,
100
+ onUnresolve,
101
+ onClose,
102
+ allowEditing = true,
103
+ autoScroll = true,
104
+ renderMessage,
105
+ className = "",
106
+ }: ThreadProps) {
107
+ const { userId, styles } = useComments();
108
+ const messagesEndRef = useRef<HTMLDivElement>(null);
109
+ const prevMessageCount = useRef(messages.length);
110
+
111
+ // Auto-scroll to bottom on new messages
112
+ useEffect(() => {
113
+ if (autoScroll && messages.length > prevMessageCount.current) {
114
+ messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
115
+ }
116
+ prevMessageCount.current = messages.length;
117
+ }, [messages.length, autoScroll]);
118
+
119
+ const handleSubmit = (body: string, attachments?: Array<{ type: "url" | "file" | "image"; url: string; name?: string }>) => {
120
+ onSubmit?.(body, attachments);
121
+ };
122
+
123
+ return (
124
+ <div
125
+ className={`thread-container ${className}`}
126
+ style={{
127
+ fontFamily: styles?.fontFamily ?? "system-ui, -apple-system, sans-serif",
128
+ display: "flex",
129
+ flexDirection: "column",
130
+ height: "100%",
131
+ border: "1px solid #e5e7eb",
132
+ borderRadius: styles?.borderRadius ?? "8px",
133
+ overflow: "hidden",
134
+ }}
135
+ >
136
+ {/* Header */}
137
+ <div
138
+ style={{
139
+ padding: "12px 16px",
140
+ borderBottom: "1px solid #e5e7eb",
141
+ display: "flex",
142
+ justifyContent: "space-between",
143
+ alignItems: "center",
144
+ backgroundColor: thread.resolved ? "#f0fdf4" : "#f9fafb",
145
+ }}
146
+ >
147
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
148
+ {onClose && (
149
+ <button
150
+ onClick={onClose}
151
+ style={{
152
+ padding: "4px 8px",
153
+ border: "none",
154
+ background: "transparent",
155
+ cursor: "pointer",
156
+ fontSize: "16px",
157
+ }}
158
+ >
159
+
160
+ </button>
161
+ )}
162
+ <span style={{ fontWeight: 600, fontSize: "14px" }}>
163
+ Thread
164
+ {thread.resolved && (
165
+ <span style={{ color: "#16a34a", marginLeft: "8px", fontWeight: 400 }}>
166
+ ✓ Resolved
167
+ </span>
168
+ )}
169
+ </span>
170
+ </div>
171
+ <div style={{ display: "flex", gap: "8px" }}>
172
+ {thread.resolved && onUnresolve && (
173
+ <button
174
+ onClick={onUnresolve}
175
+ style={{
176
+ padding: "6px 12px",
177
+ fontSize: "13px",
178
+ border: "1px solid #d1d5db",
179
+ borderRadius: "6px",
180
+ background: "white",
181
+ cursor: "pointer",
182
+ }}
183
+ >
184
+ Reopen
185
+ </button>
186
+ )}
187
+ {!thread.resolved && onResolve && userId && (
188
+ <button
189
+ onClick={onResolve}
190
+ style={{
191
+ padding: "6px 12px",
192
+ fontSize: "13px",
193
+ fontWeight: 500,
194
+ border: "none",
195
+ borderRadius: "6px",
196
+ backgroundColor: "#16a34a",
197
+ color: "white",
198
+ cursor: "pointer",
199
+ }}
200
+ >
201
+ Resolve
202
+ </button>
203
+ )}
204
+ </div>
205
+ </div>
206
+
207
+ {/* Messages */}
208
+ <div
209
+ style={{
210
+ flex: 1,
211
+ overflowY: "auto",
212
+ padding: "16px",
213
+ }}
214
+ >
215
+ {/* Load more button */}
216
+ {hasMore && (
217
+ <div style={{ textAlign: "center", marginBottom: "16px" }}>
218
+ <button
219
+ onClick={onLoadMore}
220
+ disabled={isLoading}
221
+ style={{
222
+ padding: "8px 16px",
223
+ fontSize: "13px",
224
+ border: "1px solid #d1d5db",
225
+ borderRadius: "6px",
226
+ background: "white",
227
+ cursor: isLoading ? "not-allowed" : "pointer",
228
+ opacity: isLoading ? 0.6 : 1,
229
+ }}
230
+ >
231
+ {isLoading ? "Loading..." : "Load Earlier Messages"}
232
+ </button>
233
+ </div>
234
+ )}
235
+
236
+ {/* Message list */}
237
+ {messages.length === 0 ? (
238
+ <div
239
+ style={{
240
+ textAlign: "center",
241
+ color: "#6b7280",
242
+ fontSize: "14px",
243
+ padding: "40px 0",
244
+ }}
245
+ >
246
+ No messages yet. Start the conversation!
247
+ </div>
248
+ ) : (
249
+ <div style={{ display: "flex", flexDirection: "column", gap: "12px" }}>
250
+ {messages.map((message) => {
251
+ const isMine = userId === message.message.authorId;
252
+ return renderMessage ? (
253
+ renderMessage(message, isMine)
254
+ ) : (
255
+ <Comment
256
+ key={message.message._id}
257
+ comment={message}
258
+ mine={isMine}
259
+ onToggleReaction={
260
+ onToggleReaction
261
+ ? (emoji) => onToggleReaction(message.message._id, emoji)
262
+ : undefined
263
+ }
264
+ onEdit={
265
+ onEditMessage && isMine
266
+ ? (newBody) => onEditMessage(message.message._id, newBody)
267
+ : undefined
268
+ }
269
+ onDelete={
270
+ onDeleteMessage && isMine
271
+ ? () => onDeleteMessage(message.message._id)
272
+ : undefined
273
+ }
274
+ />
275
+ );
276
+ })}
277
+ </div>
278
+ )}
279
+
280
+ {/* Typing indicator */}
281
+ {typingUsers.length > 0 && (
282
+ <div style={{ marginTop: "12px" }}>
283
+ <TypingIndicator users={typingUsers.map((u) => u.userId)} />
284
+ </div>
285
+ )}
286
+
287
+ {/* Scroll anchor */}
288
+ <div ref={messagesEndRef} />
289
+ </div>
290
+
291
+ {/* Composer */}
292
+ {userId && !thread.resolved && (
293
+ <div style={{ borderTop: "1px solid #e5e7eb" }}>
294
+ <AddComment
295
+ onSubmit={handleSubmit}
296
+ onTypingChange={onTypingChange}
297
+ allowEditing={allowEditing}
298
+ placeholder="Write a reply..."
299
+ />
300
+ </div>
301
+ )}
302
+
303
+ {/* Resolved state message */}
304
+ {thread.resolved && (
305
+ <div
306
+ style={{
307
+ padding: "12px 16px",
308
+ borderTop: "1px solid #e5e7eb",
309
+ textAlign: "center",
310
+ color: "#6b7280",
311
+ fontSize: "13px",
312
+ backgroundColor: "#f9fafb",
313
+ }}
314
+ >
315
+ This thread has been resolved.
316
+ {onUnresolve && (
317
+ <button
318
+ onClick={onUnresolve}
319
+ style={{
320
+ marginLeft: "8px",
321
+ padding: "0",
322
+ border: "none",
323
+ background: "transparent",
324
+ color: styles?.accentColor ?? "#3b82f6",
325
+ cursor: "pointer",
326
+ textDecoration: "underline",
327
+ }}
328
+ >
329
+ Reopen
330
+ </button>
331
+ )}
332
+ </div>
333
+ )}
334
+ </div>
335
+ );
336
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * TypingIndicator Component
3
+ *
4
+ * Shows who is currently typing in a thread.
5
+ */
6
+
7
+ import { useState, useEffect } from "react";
8
+ import { useComments } from "./CommentsProvider";
9
+
10
+ // ============================================================================
11
+ // Types
12
+ // ============================================================================
13
+
14
+ export interface TypingIndicatorProps {
15
+ /** User IDs currently typing */
16
+ users: string[];
17
+ /** Max number of names to show before "and X others" */
18
+ maxNamesToShow?: number;
19
+ /** CSS class name */
20
+ className?: string;
21
+ }
22
+
23
+ // ============================================================================
24
+ // Component
25
+ // ============================================================================
26
+
27
+ /**
28
+ * Animated typing indicator showing who is typing.
29
+ *
30
+ * Usage:
31
+ * ```tsx
32
+ * const typingUsers = useQuery(api.comments.getTypingUsers, { threadId });
33
+ *
34
+ * <TypingIndicator users={typingUsers.map(u => u.userId)} />
35
+ * ```
36
+ */
37
+ export function TypingIndicator({
38
+ users,
39
+ maxNamesToShow = 3,
40
+ className = "",
41
+ }: TypingIndicatorProps) {
42
+ const { resolveUser } = useComments();
43
+ const [userNames, setUserNames] = useState<Record<string, string>>({});
44
+
45
+ // Resolve user names
46
+ useEffect(() => {
47
+ if (resolveUser) {
48
+ users.forEach(async (userId) => {
49
+ if (!userNames[userId]) {
50
+ const info = await resolveUser(userId);
51
+ setUserNames((prev) => ({ ...prev, [userId]: info.name }));
52
+ }
53
+ });
54
+ }
55
+ }, [users, resolveUser, userNames]);
56
+
57
+ if (users.length === 0) {
58
+ return null;
59
+ }
60
+
61
+ // Format the typing message
62
+ const names = users.map((id) => userNames[id] ?? id);
63
+ let message: string;
64
+
65
+ if (names.length === 1) {
66
+ message = `${names[0]} is typing`;
67
+ } else if (names.length <= maxNamesToShow) {
68
+ const lastN = names[names.length - 1];
69
+ const restNames = names.slice(0, -1).join(", ");
70
+ message = `${restNames} and ${lastN} are typing`;
71
+ } else {
72
+ const shown = names.slice(0, maxNamesToShow).join(", ");
73
+ const remaining = names.length - maxNamesToShow;
74
+ message = `${shown} and ${remaining} others are typing`;
75
+ }
76
+
77
+ return (
78
+ <div
79
+ className={`typing-indicator ${className}`}
80
+ style={{
81
+ display: "flex",
82
+ alignItems: "center",
83
+ gap: "8px",
84
+ fontSize: "13px",
85
+ color: "#6b7280",
86
+ fontStyle: "italic",
87
+ }}
88
+ >
89
+ {/* Animated dots */}
90
+ <span
91
+ style={{
92
+ display: "inline-flex",
93
+ gap: "2px",
94
+ }}
95
+ >
96
+ <span
97
+ style={{
98
+ width: "4px",
99
+ height: "4px",
100
+ borderRadius: "50%",
101
+ backgroundColor: "#9ca3af",
102
+ animation: "typingDot 1.4s infinite ease-in-out",
103
+ animationDelay: "0s",
104
+ }}
105
+ />
106
+ <span
107
+ style={{
108
+ width: "4px",
109
+ height: "4px",
110
+ borderRadius: "50%",
111
+ backgroundColor: "#9ca3af",
112
+ animation: "typingDot 1.4s infinite ease-in-out",
113
+ animationDelay: "0.2s",
114
+ }}
115
+ />
116
+ <span
117
+ style={{
118
+ width: "4px",
119
+ height: "4px",
120
+ borderRadius: "50%",
121
+ backgroundColor: "#9ca3af",
122
+ animation: "typingDot 1.4s infinite ease-in-out",
123
+ animationDelay: "0.4s",
124
+ }}
125
+ />
126
+ </span>
127
+ <span>{message}</span>
128
+
129
+ {/* Inline styles for animation */}
130
+ <style>{`
131
+ @keyframes typingDot {
132
+ 0%, 80%, 100% {
133
+ transform: scale(1);
134
+ opacity: 0.5;
135
+ }
136
+ 40% {
137
+ transform: scale(1.2);
138
+ opacity: 1;
139
+ }
140
+ }
141
+ `}</style>
142
+ </div>
143
+ );
144
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Comments Component - React UI
3
+ *
4
+ * Drop-in React components for the comments system.
5
+ * These components provide a fully managed UI for comments,
6
+ * threads, reactions, and typing indicators.
7
+ */
8
+
9
+ export {
10
+ CommentsProvider,
11
+ useComments,
12
+ type CommentsContextValue,
13
+ type CommentsStyles,
14
+ type CommentsProviderProps,
15
+ } from "./CommentsProvider";
16
+
17
+ export {
18
+ Comments,
19
+ type CommentsProps,
20
+ } from "./Comments";
21
+
22
+ export {
23
+ Thread,
24
+ type ThreadProps,
25
+ } from "./Thread";
26
+
27
+ export {
28
+ Comment,
29
+ type CommentProps,
30
+ } from "./Comment";
31
+
32
+ export {
33
+ AddComment,
34
+ type AddCommentProps,
35
+ } from "./AddComment";
36
+
37
+ export {
38
+ ReactionPicker,
39
+ type ReactionPickerProps,
40
+ } from "./ReactionPicker";
41
+
42
+ export {
43
+ TypingIndicator,
44
+ type TypingIndicatorProps,
45
+ } from "./TypingIndicator";