diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6afba16..14b7efa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,16 +5,13 @@ default: timeout: 1 minute stages: - - build + - test - deploy -build: - stage: build +test: + stage: test script: - - npm run build - only: - variables: - - $CI_DEFAULT_BRANCH != $CI_COMMIT_REF_NAME + - npm run test pages: stage: deploy diff --git a/CONTEXT.md b/CONTEXT.md index 53a7128..092b826 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -712,6 +712,60 @@ if (!author.metadata?.lud16 && !author.metadata?.lud06) { - Detect WebLN only when needed (dialog open) - Show payment method indicator to users - Handle errors gracefully with specific messaging +### 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 diff --git a/package-lock.json b/package-lock.json index 68c26c4..3e16e45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,8 @@ "@fontsource-variable/inter": "^5.2.6", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", - "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1", - "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", + "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3", + "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", @@ -1249,15 +1249,15 @@ } }, "node_modules/@jsr/nostrify__nostrify": { - "version": "0.46.1", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz", - "integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==", + "version": "0.46.3", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz", + "integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==", "dependencies": { "@jsr/nostrify__types": "^0.36.0", "@jsr/scure__base": "^1.2.4", "@jsr/std__encoding": "^0.224.1", "lru-cache": "^10.2.0", - "nostr-tools": "^2.10.4", + "nostr-tools": "^2.13.0", "websocket-ts": "^2.2.1", "zod": "^3.23.8" } @@ -1359,27 +1359,27 @@ }, "node_modules/@nostrify/nostrify": { "name": "@jsr/nostrify__nostrify", - "version": "0.46.1", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.1.tgz", - "integrity": "sha512-7XSP4+kjcPgw937jQPUxf+qo1mWx7rbKUuA5ma0CofRQZa2v01IA4f+OfVGJbABxbJ8SC+8VdPkLqqUOJxeVPg==", + "version": "0.46.3", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__nostrify/0.46.3.tgz", + "integrity": "sha512-zJpOrD8bbrJroLRJjESAJZX/ZKFCaGfoz5fSfLP+gIcTiPo8JpzlrFBF6mvaDI/Mdd+1WTBwlCcW9On8rUVH7w==", "dependencies": { "@jsr/nostrify__types": "^0.36.0", "@jsr/scure__base": "^1.2.4", "@jsr/std__encoding": "^0.224.1", "lru-cache": "^10.2.0", - "nostr-tools": "^2.10.4", + "nostr-tools": "^2.13.0", "websocket-ts": "^2.2.1", "zod": "^3.23.8" } }, "node_modules/@nostrify/react": { "name": "@jsr/nostrify__react", - "version": "0.2.5", - "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.5.tgz", - "integrity": "sha512-Hyi1N4hXa89gYsuk7fqGrWEzIzTp7md8hKog+D2DgpSqVwFQM1vw267Uv3rOieE/hPereD3gen9X88XkeAXP+Q==", + "version": "0.2.7", + "resolved": "https://npm.jsr.io/~/11/@jsr/nostrify__react/0.2.7.tgz", + "integrity": "sha512-36fAOOymf34KR2OfE4jXBojbZnPsrIzQDAXzE7dko8/Qj+0s8iKnZoZKg9DVIT2F6Lxhr6SUiTze8xBxuJbf1A==", "dependencies": { - "@jsr/nostrify__nostrify": "^0.46.1", - "nostr-tools": "^2.10.4", + "@jsr/nostrify__nostrify": "^0.46.3", + "nostr-tools": "^2.13.0", "react": "^18.0.0" } }, diff --git a/package.json b/package.json index c291d7d..05e46bd 100644 --- a/package.json +++ b/package.json @@ -13,8 +13,8 @@ "@fontsource-variable/inter": "^5.2.6", "@getalby/sdk": "^5.1.1", "@hookform/resolvers": "^3.9.0", - "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.1", - "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.5", + "@nostrify/nostrify": "npm:@jsr/nostrify__nostrify@^0.46.3", + "@nostrify/react": "npm:@jsr/nostrify__react@^0.2.7", "@radix-ui/react-accordion": "^1.2.0", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-aspect-ratio": "^1.1.0", 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 ( + + +
+