diff --git a/CONTEXT.md b/CONTEXT.md index 61f8a2c..d586bb0 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -651,6 +651,61 @@ export function Post(/* ...props */) { } ``` +### Adding Comments Sections + +The project includes a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event or URL. The `CommentsSection` component provides a full-featured commenting interface with threaded replies, user authentication, and real-time updates. + +#### Basic Usage + +```tsx +import { CommentsSection } from "@/components/comments/CommentsSection"; + +function ArticlePage({ article }: { article: NostrEvent }) { + return ( +
+ {/* Your article content */} +
{/* article content */}
+ + {/* Comments section */} + +
+ ); +} +``` + +#### Props and Customization + +The `CommentsSection` component accepts the following props: + +- **`root`** (required): The root event or URL to comment on. Can be a `NostrEvent` or `URL` object. +- **`title`**: Custom title for the comments section (default: "Comments") +- **`emptyStateMessage`**: Message shown when no comments exist (default: "No comments yet") +- **`emptyStateSubtitle`**: Subtitle for empty state (default: "Be the first to share your thoughts!") +- **`className`**: Additional CSS classes for styling +- **`limit`**: Maximum number of comments to load (default: 500) + +```tsx + +``` + +#### Commenting on URLs + +The comments system also supports commenting on external URLs, making it useful for web pages, articles, or any online content: + +```tsx + +``` + ## App Configuration The project includes an `AppProvider` that manages global application state including theme and relay configuration. The default configuration includes: diff --git a/src/components/comments/Comment.tsx b/src/components/comments/Comment.tsx new file mode 100644 index 0000000..54658ae --- /dev/null +++ b/src/components/comments/Comment.tsx @@ -0,0 +1,153 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { NostrEvent } from '@nostrify/nostrify'; +import { nip19 } from 'nostr-tools'; +import { useAuthor } from '@/hooks/useAuthor'; +import { useComments } from '@/hooks/useComments'; +import { CommentForm } from './CommentForm'; +import { NoteContent } from '@/components/NoteContent'; +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { DropdownMenu, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'; +import { MessageSquare, ChevronDown, ChevronRight, MoreHorizontal } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { genUserName } from '@/lib/genUserName'; + +interface CommentProps { + root: NostrEvent | URL; + comment: NostrEvent; + depth?: number; + maxDepth?: number; + limit?: number; +} + +export function Comment({ root, comment, depth = 0, maxDepth = 3, limit }: CommentProps) { + const [showReplyForm, setShowReplyForm] = useState(false); + const [showReplies, setShowReplies] = useState(depth < 2); // Auto-expand first 2 levels + + const author = useAuthor(comment.pubkey); + const { data: commentsData } = useComments(root, limit); + + const metadata = author.data?.metadata; + const displayName = metadata?.name ?? genUserName(comment.pubkey) + const timeAgo = formatDistanceToNow(new Date(comment.created_at * 1000), { addSuffix: true }); + + // Get direct replies to this comment + const replies = commentsData?.getDirectReplies(comment.id) || []; + const hasReplies = replies.length > 0; + + return ( +
0 ? 'ml-6 border-l-2 border-muted pl-4' : ''}`}> + + +
+ {/* Comment Header */} +
+
+ + + + + {displayName.charAt(0)} + + + +
+ + {displayName} + +

{timeAgo}

+
+
+
+ + {/* Comment Content */} +
+ +
+ + {/* Comment Actions */} +
+
+ + + {hasReplies && ( + + + + + + )} +
+ + {/* Comment menu */} + + + + + +
+
+
+
+ + {/* Reply Form */} + {showReplyForm && ( +
+ setShowReplyForm(false)} + placeholder="Write a reply..." + compact + /> +
+ )} + + {/* Replies */} + {hasReplies && ( + + + {replies.map((reply) => ( + + ))} + + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/comments/CommentForm.tsx b/src/components/comments/CommentForm.tsx new file mode 100644 index 0000000..7c973f2 --- /dev/null +++ b/src/components/comments/CommentForm.tsx @@ -0,0 +1,90 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { Textarea } from '@/components/ui/textarea'; +import { Card, CardContent } from '@/components/ui/card'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { usePostComment } from '@/hooks/usePostComment'; +import { LoginArea } from '@/components/auth/LoginArea'; +import { NostrEvent } from '@nostrify/nostrify'; +import { MessageSquare, Send } from 'lucide-react'; + +interface CommentFormProps { + root: NostrEvent | URL; + reply?: NostrEvent | URL; + onSuccess?: () => void; + placeholder?: string; + compact?: boolean; +} + +export function CommentForm({ + root, + reply, + onSuccess, + placeholder = "Write a comment...", + compact = false +}: CommentFormProps) { + const [content, setContent] = useState(''); + const { user } = useCurrentUser(); + const { mutate: postComment, isPending } = usePostComment(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!content.trim() || !user) return; + + postComment( + { content: content.trim(), root, reply }, + { + onSuccess: () => { + setContent(''); + onSuccess?.(); + }, + } + ); + }; + + if (!user) { + return ( + + +
+
+ + Sign in to {reply ? 'reply' : 'comment'} +
+ +
+
+
+ ); + } + + return ( + + +
+