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 (
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/components/comments/CommentsSection.tsx b/src/components/comments/CommentsSection.tsx
new file mode 100644
index 0000000..a269934
--- /dev/null
+++ b/src/components/comments/CommentsSection.tsx
@@ -0,0 +1,100 @@
+import { useComments } from '@/hooks/useComments';
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
+import { Skeleton } from '@/components/ui/skeleton';
+import { MessageSquare } from 'lucide-react';
+import { cn } from '@/lib/utils';
+import type { NostrEvent } from '@nostrify/nostrify';
+import { CommentForm } from './CommentForm';
+import { Comment } from './Comment';
+
+interface CommentsSectionProps {
+ root: NostrEvent | URL;
+ title?: string;
+ emptyStateMessage?: string;
+ emptyStateSubtitle?: string;
+ className?: string;
+ limit?: number;
+}
+
+export function CommentsSection({
+ root,
+ title = "Comments",
+ emptyStateMessage = "No comments yet",
+ emptyStateSubtitle = "Be the first to share your thoughts!",
+ className,
+ limit = 500,
+}: CommentsSectionProps) {
+ const { data: commentsData, isLoading, error } = useComments(root, limit);
+ const comments = commentsData?.topLevelComments || [];
+
+ if (error) {
+ return (
+
+
+
+
+
Failed to load comments
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ {title}
+ {!isLoading && (
+
+ ({comments.length})
+
+ )}
+
+
+
+ {/* Comment Form */}
+
+
+ {/* Comments List */}
+ {isLoading ? (
+
+ {[...Array(3)].map((_, i) => (
+
+
+
+
+
+ ))}
+
+ ) : comments.length === 0 ? (
+
+
+
{emptyStateMessage}
+
{emptyStateSubtitle}
+
+ ) : (
+
+ {comments.map((comment) => (
+
+ ))}
+
+ )}
+
+
+ );
+}
\ No newline at end of file
diff --git a/src/hooks/useComments.ts b/src/hooks/useComments.ts
new file mode 100644
index 0000000..49922c5
--- /dev/null
+++ b/src/hooks/useComments.ts
@@ -0,0 +1,98 @@
+import { NKinds, NostrEvent, NostrFilter } from '@nostrify/nostrify';
+import { useNostr } from '@nostrify/react';
+import { useQuery } from '@tanstack/react-query';
+
+export function useComments(root: NostrEvent | URL, limit?: number) {
+ const { nostr } = useNostr();
+
+ return useQuery({
+ queryKey: ['comments', root instanceof URL ? root.toString() : root.id, limit],
+ queryFn: async (c) => {
+ const filter: NostrFilter = { kinds: [1111] };
+
+ if (root instanceof URL) {
+ filter['#I'] = [root.toString()];
+ } else if (NKinds.addressable(root.kind)) {
+ const d = root.tags.find(([name]) => name === 'd')?.[1] ?? '';
+ filter['#A'] = [`${root.kind}:${root.pubkey}:${d}`];
+ } else if (NKinds.replaceable(root.kind)) {
+ filter['#A'] = [`${root.kind}:${root.pubkey}:`];
+ } else {
+ filter['#E'] = [root.id];
+ }
+
+ if (typeof limit === 'number') {
+ filter.limit = limit;
+ }
+
+ // Query for all kind 1111 comments that reference this addressable event regardless of depth
+ const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
+ const events = await nostr.query([filter], { signal });
+
+ // Helper function to get tag value
+ const getTagValue = (event: NostrEvent, tagName: string): string | undefined => {
+ const tag = event.tags.find(([name]) => name === tagName);
+ return tag?.[1];
+ };
+
+ // Filter top-level comments (those with lowercase tag matching the root)
+ const topLevelComments = events.filter(comment => {
+ if (root instanceof URL) {
+ return getTagValue(comment, 'i') === root.toString();
+ } else if (NKinds.addressable(root.kind)) {
+ const d = getTagValue(root, 'd') ?? '';
+ return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:${d}`;
+ } else if (NKinds.replaceable(root.kind)) {
+ return getTagValue(comment, 'a') === `${root.kind}:${root.pubkey}:`;
+ } else {
+ return getTagValue(comment, 'e') === root.id;
+ }
+ });
+
+ // Helper function to get all descendants of a comment
+ const getDescendants = (parentId: string): NostrEvent[] => {
+ const directReplies = events.filter(comment => {
+ const eTag = getTagValue(comment, 'e');
+ return eTag === parentId;
+ });
+
+ const allDescendants = [...directReplies];
+
+ // Recursively get descendants of each direct reply
+ for (const reply of directReplies) {
+ allDescendants.push(...getDescendants(reply.id));
+ }
+
+ return allDescendants;
+ };
+
+ // Create a map of comment ID to its descendants
+ const commentDescendants = new Map();
+ for (const comment of events) {
+ commentDescendants.set(comment.id, getDescendants(comment.id));
+ }
+
+ // Sort top-level comments by creation time (newest first)
+ const sortedTopLevel = topLevelComments.sort((a, b) => b.created_at - a.created_at);
+
+ return {
+ allComments: events,
+ topLevelComments: sortedTopLevel,
+ getDescendants: (commentId: string) => {
+ const descendants = commentDescendants.get(commentId) || [];
+ // Sort descendants by creation time (oldest first for threaded display)
+ return descendants.sort((a, b) => a.created_at - b.created_at);
+ },
+ getDirectReplies: (commentId: string) => {
+ const directReplies = events.filter(comment => {
+ const eTag = getTagValue(comment, 'e');
+ return eTag === commentId;
+ });
+ // Sort direct replies by creation time (oldest first for threaded display)
+ return directReplies.sort((a, b) => a.created_at - b.created_at);
+ }
+ };
+ },
+ enabled: !!root,
+ });
+}
\ No newline at end of file
diff --git a/src/hooks/usePostComment.ts b/src/hooks/usePostComment.ts
new file mode 100644
index 0000000..30fe3eb
--- /dev/null
+++ b/src/hooks/usePostComment.ts
@@ -0,0 +1,92 @@
+import { useMutation, useQueryClient } from '@tanstack/react-query';
+import { useNostrPublish } from '@/hooks/useNostrPublish';
+import { NKinds, type NostrEvent } from '@nostrify/nostrify';
+
+interface PostCommentParams {
+ root: NostrEvent | URL; // The root event to comment on
+ reply?: NostrEvent | URL; // Optional reply to another comment
+ content: string;
+}
+
+/** Post a NIP-22 (kind 1111) comment on an event. */
+export function usePostComment() {
+ const { mutateAsync: publishEvent } = useNostrPublish();
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ root, reply, content }: PostCommentParams) => {
+ const tags: string[][] = [];
+
+ // d-tag identifiers
+ const dRoot = root instanceof URL ? '' : root.tags.find(([name]) => name === 'd')?.[1] ?? '';
+ const dReply = reply instanceof URL ? '' : reply?.tags.find(([name]) => name === 'd')?.[1] ?? '';
+
+ // Root event tags
+ if (root instanceof URL) {
+ tags.push(['I', root.toString()]);
+ } else if (NKinds.addressable(root.kind)) {
+ tags.push(['A', `${root.kind}:${root.pubkey}:${dRoot}`]);
+ } else if (NKinds.replaceable(root.kind)) {
+ tags.push(['A', `${root.kind}:${root.pubkey}:`]);
+ } else {
+ tags.push(['E', root.id]);
+ }
+ if (root instanceof URL) {
+ tags.push(['K', root.hostname]);
+ } else {
+ tags.push(['K', root.kind.toString()]);
+ tags.push(['P', root.pubkey]);
+ }
+
+ // Reply event tags
+ if (reply) {
+ if (reply instanceof URL) {
+ tags.push(['i', reply.toString()]);
+ } else if (NKinds.addressable(reply.kind)) {
+ tags.push(['a', `${reply.kind}:${reply.pubkey}:${dReply}`]);
+ } else if (NKinds.replaceable(reply.kind)) {
+ tags.push(['a', `${reply.kind}:${reply.pubkey}:`]);
+ } else {
+ tags.push(['e', reply.id]);
+ }
+ if (reply instanceof URL) {
+ tags.push(['k', reply.hostname]);
+ } else {
+ tags.push(['k', reply.kind.toString()]);
+ tags.push(['p', reply.pubkey]);
+ }
+ } else {
+ // If this is a top-level comment, use the root event's tags
+ if (root instanceof URL) {
+ tags.push(['i', root.toString()]);
+ } else if (NKinds.addressable(root.kind)) {
+ tags.push(['a', `${root.kind}:${root.pubkey}:${dRoot}`]);
+ } else if (NKinds.replaceable(root.kind)) {
+ tags.push(['a', `${root.kind}:${root.pubkey}:`]);
+ } else {
+ tags.push(['e', root.id]);
+ }
+ if (root instanceof URL) {
+ tags.push(['k', root.hostname]);
+ } else {
+ tags.push(['k', root.kind.toString()]);
+ tags.push(['p', root.pubkey]);
+ }
+ }
+
+ const event = await publishEvent({
+ kind: 1111,
+ content,
+ tags,
+ });
+
+ return event;
+ },
+ onSuccess: (_, { root }) => {
+ // Invalidate and refetch comments
+ queryClient.invalidateQueries({
+ queryKey: ['comments', root instanceof URL ? root.toString() : root.id]
+ });
+ },
+ });
+}
\ No newline at end of file