@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,519 @@
1
+ /**
2
+ * Comment Component
3
+ *
4
+ * Displays a single comment/message with reactions and actions.
5
+ */
6
+
7
+ import { useState, useEffect } from "react";
8
+ import { useComments } from "./CommentsProvider";
9
+ import { ReactionPicker } from "./ReactionPicker";
10
+
11
+ // ============================================================================
12
+ // Types
13
+ // ============================================================================
14
+
15
+ export interface MessageData {
16
+ message: {
17
+ _id: string;
18
+ threadId: string;
19
+ authorId: string;
20
+ body: string;
21
+ mentions: Array<{ userId: string; start: number; end: number }>;
22
+ links: Array<{ url: string; start: number; end: number }>;
23
+ attachments: Array<{
24
+ type: "url" | "file" | "image";
25
+ url: string;
26
+ name?: string;
27
+ mimeType?: string;
28
+ size?: number;
29
+ }>;
30
+ isEdited: boolean;
31
+ isDeleted: boolean;
32
+ createdAt: number;
33
+ editedAt?: number;
34
+ };
35
+ reactions: Array<{
36
+ emoji: string;
37
+ count: number;
38
+ users: string[];
39
+ includesMe: boolean;
40
+ }>;
41
+ }
42
+
43
+ export interface CommentProps {
44
+ /** Comment data */
45
+ comment: MessageData;
46
+ /** Whether this comment belongs to the current user */
47
+ mine?: boolean;
48
+ /** Callback when a reaction is toggled */
49
+ onToggleReaction?: (emoji: string) => void;
50
+ /** Callback when the comment is edited */
51
+ onEdit?: (newBody: string) => void;
52
+ /** Callback when the comment is deleted */
53
+ onDelete?: () => void;
54
+ /** CSS class name */
55
+ className?: string;
56
+ }
57
+
58
+ // ============================================================================
59
+ // Helpers
60
+ // ============================================================================
61
+
62
+ function formatTime(timestamp: number): string {
63
+ const date = new Date(timestamp);
64
+ const now = new Date();
65
+ const diffMs = now.getTime() - date.getTime();
66
+ const diffMins = Math.floor(diffMs / 60000);
67
+ const diffHours = Math.floor(diffMs / 3600000);
68
+ const diffDays = Math.floor(diffMs / 86400000);
69
+
70
+ if (diffMins < 1) return "just now";
71
+ if (diffMins < 60) return `${diffMins}m ago`;
72
+ if (diffHours < 24) return `${diffHours}h ago`;
73
+ if (diffDays < 7) return `${diffDays}d ago`;
74
+
75
+ return date.toLocaleDateString();
76
+ }
77
+
78
+ function renderBodyWithMentionsAndLinks(
79
+ body: string,
80
+ mentions: MessageData["message"]["mentions"],
81
+ links: MessageData["message"]["links"]
82
+ ): React.ReactNode {
83
+ // Combine mentions and links, sort by start position
84
+ const segments: Array<{
85
+ type: "mention" | "link";
86
+ start: number;
87
+ end: number;
88
+ data: { userId?: string; url?: string };
89
+ }> = [
90
+ ...mentions.map((m) => ({
91
+ type: "mention" as const,
92
+ start: m.start,
93
+ end: m.end,
94
+ data: { userId: m.userId },
95
+ })),
96
+ ...links.map((l) => ({
97
+ type: "link" as const,
98
+ start: l.start,
99
+ end: l.end,
100
+ data: { url: l.url },
101
+ })),
102
+ ].sort((a, b) => a.start - b.start);
103
+
104
+ if (segments.length === 0) {
105
+ return body;
106
+ }
107
+
108
+ const parts: React.ReactNode[] = [];
109
+ let lastIndex = 0;
110
+
111
+ segments.forEach((segment, i) => {
112
+ // Add text before this segment
113
+ if (segment.start > lastIndex) {
114
+ parts.push(body.slice(lastIndex, segment.start));
115
+ }
116
+
117
+ // Add the highlighted segment
118
+ const segmentText = body.slice(segment.start, segment.end);
119
+ if (segment.type === "mention") {
120
+ parts.push(
121
+ <span
122
+ key={`mention-${i}`}
123
+ style={{
124
+ color: "#3b82f6",
125
+ fontWeight: 500,
126
+ backgroundColor: "#eff6ff",
127
+ padding: "0 2px",
128
+ borderRadius: "2px",
129
+ }}
130
+ >
131
+ {segmentText}
132
+ </span>
133
+ );
134
+ } else {
135
+ parts.push(
136
+ <a
137
+ key={`link-${i}`}
138
+ href={segment.data.url}
139
+ target="_blank"
140
+ rel="noopener noreferrer"
141
+ style={{
142
+ color: "#3b82f6",
143
+ textDecoration: "underline",
144
+ }}
145
+ >
146
+ {segmentText}
147
+ </a>
148
+ );
149
+ }
150
+
151
+ lastIndex = segment.end;
152
+ });
153
+
154
+ // Add remaining text
155
+ if (lastIndex < body.length) {
156
+ parts.push(body.slice(lastIndex));
157
+ }
158
+
159
+ return parts;
160
+ }
161
+
162
+ // ============================================================================
163
+ // Component
164
+ // ============================================================================
165
+
166
+ /**
167
+ * Component to display a single comment with reactions and actions.
168
+ *
169
+ * Usage:
170
+ * ```tsx
171
+ * <Comment
172
+ * comment={messageData}
173
+ * mine={true}
174
+ * onToggleReaction={(emoji) => toggleReaction({ messageId, emoji })}
175
+ * onEdit={(newBody) => editMessage({ messageId, body: newBody })}
176
+ * onDelete={() => deleteMessage({ messageId })}
177
+ * />
178
+ * ```
179
+ */
180
+ export function Comment({
181
+ comment,
182
+ mine = false,
183
+ onToggleReaction,
184
+ onEdit,
185
+ onDelete,
186
+ className = "",
187
+ }: CommentProps) {
188
+ const { resolveUser, canModerate, styles } = useComments();
189
+ const [isEditing, setIsEditing] = useState(false);
190
+ const [editBody, setEditBody] = useState(comment.message.body);
191
+ const [showReactionPicker, setShowReactionPicker] = useState(false);
192
+ const [showActions, setShowActions] = useState(false);
193
+ const [authorInfo, setAuthorInfo] = useState<{ name: string; avatar?: string } | null>(null);
194
+
195
+ // Resolve author info
196
+ useEffect(() => {
197
+ if (resolveUser) {
198
+ Promise.resolve(resolveUser(comment.message.authorId)).then(setAuthorInfo);
199
+ }
200
+ }, [comment.message.authorId, resolveUser]);
201
+
202
+ const handleSaveEdit = () => {
203
+ if (editBody.trim() && editBody !== comment.message.body) {
204
+ onEdit?.(editBody.trim());
205
+ }
206
+ setIsEditing(false);
207
+ };
208
+
209
+ const handleCancelEdit = () => {
210
+ setEditBody(comment.message.body);
211
+ setIsEditing(false);
212
+ };
213
+
214
+ const canEdit = (mine || canModerate) && onEdit && !comment.message.isDeleted;
215
+ const canDelete = (mine || canModerate) && onDelete && !comment.message.isDeleted;
216
+
217
+ // Deleted message
218
+ if (comment.message.isDeleted) {
219
+ return (
220
+ <div
221
+ className={`comment comment-deleted ${className}`}
222
+ style={{
223
+ padding: "12px",
224
+ color: "#9ca3af",
225
+ fontStyle: "italic",
226
+ fontSize: "13px",
227
+ }}
228
+ >
229
+ This message was deleted.
230
+ </div>
231
+ );
232
+ }
233
+
234
+ return (
235
+ <div
236
+ className={`comment ${mine ? "comment-mine" : ""} ${className}`}
237
+ onMouseEnter={() => setShowActions(true)}
238
+ onMouseLeave={() => {
239
+ setShowActions(false);
240
+ setShowReactionPicker(false);
241
+ }}
242
+ style={{
243
+ position: "relative",
244
+ padding: "12px",
245
+ backgroundColor: mine ? "#eff6ff" : "#f9fafb",
246
+ borderRadius: styles?.borderRadius ?? "8px",
247
+ }}
248
+ >
249
+ {/* Author and time */}
250
+ <div
251
+ style={{
252
+ display: "flex",
253
+ justifyContent: "space-between",
254
+ alignItems: "center",
255
+ marginBottom: "8px",
256
+ }}
257
+ >
258
+ <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
259
+ {authorInfo?.avatar ? (
260
+ <img
261
+ src={authorInfo.avatar}
262
+ alt=""
263
+ style={{
264
+ width: "24px",
265
+ height: "24px",
266
+ borderRadius: "50%",
267
+ }}
268
+ />
269
+ ) : (
270
+ <div
271
+ style={{
272
+ width: "24px",
273
+ height: "24px",
274
+ borderRadius: "50%",
275
+ backgroundColor: "#d1d5db",
276
+ display: "flex",
277
+ alignItems: "center",
278
+ justifyContent: "center",
279
+ fontSize: "12px",
280
+ fontWeight: 600,
281
+ color: "#4b5563",
282
+ }}
283
+ >
284
+ {(authorInfo?.name ?? comment.message.authorId).charAt(0).toUpperCase()}
285
+ </div>
286
+ )}
287
+ <span style={{ fontWeight: 600, fontSize: "13px", color: "#111827" }}>
288
+ {authorInfo?.name ?? comment.message.authorId}
289
+ </span>
290
+ {mine && (
291
+ <span
292
+ style={{
293
+ fontSize: "11px",
294
+ color: "#6b7280",
295
+ backgroundColor: "#e5e7eb",
296
+ padding: "1px 6px",
297
+ borderRadius: "4px",
298
+ }}
299
+ >
300
+ You
301
+ </span>
302
+ )}
303
+ </div>
304
+ <span style={{ fontSize: "12px", color: "#6b7280" }}>
305
+ {formatTime(comment.message.createdAt)}
306
+ {comment.message.isEdited && " (edited)"}
307
+ </span>
308
+ </div>
309
+
310
+ {/* Message body */}
311
+ {isEditing ? (
312
+ <div style={{ marginBottom: "8px" }}>
313
+ <textarea
314
+ value={editBody}
315
+ onChange={(e) => setEditBody(e.target.value)}
316
+ style={{
317
+ width: "100%",
318
+ padding: "8px",
319
+ border: "1px solid #d1d5db",
320
+ borderRadius: "6px",
321
+ fontSize: "14px",
322
+ resize: "vertical",
323
+ minHeight: "60px",
324
+ }}
325
+ autoFocus
326
+ />
327
+ <div style={{ display: "flex", gap: "8px", marginTop: "8px" }}>
328
+ <button
329
+ onClick={handleSaveEdit}
330
+ style={{
331
+ padding: "4px 12px",
332
+ fontSize: "13px",
333
+ fontWeight: 500,
334
+ border: "none",
335
+ borderRadius: "4px",
336
+ backgroundColor: styles?.accentColor ?? "#3b82f6",
337
+ color: "white",
338
+ cursor: "pointer",
339
+ }}
340
+ >
341
+ Save
342
+ </button>
343
+ <button
344
+ onClick={handleCancelEdit}
345
+ style={{
346
+ padding: "4px 12px",
347
+ fontSize: "13px",
348
+ border: "1px solid #d1d5db",
349
+ borderRadius: "4px",
350
+ background: "white",
351
+ cursor: "pointer",
352
+ }}
353
+ >
354
+ Cancel
355
+ </button>
356
+ </div>
357
+ </div>
358
+ ) : (
359
+ <div style={{ fontSize: "14px", color: "#111827", lineHeight: 1.5 }}>
360
+ {renderBodyWithMentionsAndLinks(
361
+ comment.message.body,
362
+ comment.message.mentions,
363
+ comment.message.links
364
+ )}
365
+ </div>
366
+ )}
367
+
368
+ {/* Attachments */}
369
+ {comment.message.attachments.length > 0 && (
370
+ <div style={{ marginTop: "8px", display: "flex", flexWrap: "wrap", gap: "8px" }}>
371
+ {comment.message.attachments.map((attachment, i) => (
372
+ <a
373
+ key={i}
374
+ href={attachment.url}
375
+ target="_blank"
376
+ rel="noopener noreferrer"
377
+ style={{
378
+ display: "inline-flex",
379
+ alignItems: "center",
380
+ gap: "4px",
381
+ padding: "4px 8px",
382
+ backgroundColor: "#e5e7eb",
383
+ borderRadius: "4px",
384
+ fontSize: "12px",
385
+ color: "#374151",
386
+ textDecoration: "none",
387
+ }}
388
+ >
389
+ {attachment.type === "image" ? "🖼️" : "📎"}
390
+ {attachment.name ?? "Attachment"}
391
+ </a>
392
+ ))}
393
+ </div>
394
+ )}
395
+
396
+ {/* Reactions display */}
397
+ {comment.reactions.length > 0 && (
398
+ <div
399
+ style={{
400
+ marginTop: "8px",
401
+ display: "flex",
402
+ flexWrap: "wrap",
403
+ gap: "6px",
404
+ }}
405
+ >
406
+ {comment.reactions.map((reaction) => (
407
+ <button
408
+ key={reaction.emoji}
409
+ onClick={() => onToggleReaction?.(reaction.emoji)}
410
+ style={{
411
+ display: "inline-flex",
412
+ alignItems: "center",
413
+ gap: "4px",
414
+ padding: "2px 8px",
415
+ border: reaction.includesMe
416
+ ? `1px solid ${styles?.accentColor ?? "#3b82f6"}`
417
+ : "1px solid #d1d5db",
418
+ borderRadius: "12px",
419
+ backgroundColor: reaction.includesMe ? "#eff6ff" : "white",
420
+ fontSize: "13px",
421
+ cursor: onToggleReaction ? "pointer" : "default",
422
+ }}
423
+ >
424
+ <span>{reaction.emoji}</span>
425
+ <span style={{ color: "#6b7280" }}>{reaction.count}</span>
426
+ </button>
427
+ ))}
428
+ </div>
429
+ )}
430
+
431
+ {/* Action buttons (shown on hover) */}
432
+ {showActions && !isEditing && (
433
+ <div
434
+ style={{
435
+ position: "absolute",
436
+ top: "8px",
437
+ right: "8px",
438
+ display: "flex",
439
+ gap: "4px",
440
+ backgroundColor: "white",
441
+ padding: "4px",
442
+ borderRadius: "6px",
443
+ boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
444
+ }}
445
+ >
446
+ {onToggleReaction && (
447
+ <button
448
+ onClick={() => setShowReactionPicker(!showReactionPicker)}
449
+ style={{
450
+ padding: "4px 6px",
451
+ border: "none",
452
+ background: "transparent",
453
+ cursor: "pointer",
454
+ fontSize: "14px",
455
+ }}
456
+ title="Add reaction"
457
+ >
458
+ 😀
459
+ </button>
460
+ )}
461
+ {canEdit && (
462
+ <button
463
+ onClick={() => setIsEditing(true)}
464
+ style={{
465
+ padding: "4px 6px",
466
+ border: "none",
467
+ background: "transparent",
468
+ cursor: "pointer",
469
+ fontSize: "12px",
470
+ }}
471
+ title="Edit"
472
+ >
473
+ ✏️
474
+ </button>
475
+ )}
476
+ {canDelete && (
477
+ <button
478
+ onClick={() => {
479
+ if (window.confirm("Delete this message?")) {
480
+ onDelete?.();
481
+ }
482
+ }}
483
+ style={{
484
+ padding: "4px 6px",
485
+ border: "none",
486
+ background: "transparent",
487
+ cursor: "pointer",
488
+ fontSize: "12px",
489
+ }}
490
+ title="Delete"
491
+ >
492
+ 🗑️
493
+ </button>
494
+ )}
495
+ </div>
496
+ )}
497
+
498
+ {/* Reaction picker */}
499
+ {showReactionPicker && onToggleReaction && (
500
+ <div
501
+ style={{
502
+ position: "absolute",
503
+ top: "40px",
504
+ right: "8px",
505
+ zIndex: 10,
506
+ }}
507
+ >
508
+ <ReactionPicker
509
+ onSelect={(emoji) => {
510
+ onToggleReaction(emoji);
511
+ setShowReactionPicker(false);
512
+ }}
513
+ onClose={() => setShowReactionPicker(false)}
514
+ />
515
+ </div>
516
+ )}
517
+ </div>
518
+ );
519
+ }