Add CommentsSection for NIP-22 (kind 1111) comments

This commit is contained in:
Alex Gleason 2025-07-13 18:38:48 -05:00
parent 3b48df1093
commit f75ddb6654
No known key found for this signature in database
GPG Key ID: 7211D1F99744FBB7
6 changed files with 588 additions and 0 deletions

View File

@ -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 (
<div className="space-y-6">
{/* Your article content */}
<div>{/* article content */}</div>
{/* Comments section */}
<CommentsSection root={article} />
</div>
);
}
```
#### 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
<CommentsSection
root={event}
title="Discussion"
emptyStateMessage="Start the conversation"
emptyStateSubtitle="Share your thoughts about this post"
className="mt-8"
limit={100}
/>
```
#### Commenting on URLs
The comments system also supports commenting on external URLs, making it useful for web pages, articles, or any online content:
```tsx
<CommentsSection
root={new URL("https://example.com/article")}
title="Comments on this article"
/>
```
## App Configuration
The project includes an `AppProvider` that manages global application state including theme and relay configuration. The default configuration includes:

View File

@ -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 (
<div className={`space-y-3 ${depth > 0 ? 'ml-6 border-l-2 border-muted pl-4' : ''}`}>
<Card className="bg-card/50">
<CardContent className="p-4">
<div className="space-y-3">
{/* Comment Header */}
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<Link to={`/${nip19.npubEncode(comment.pubkey)}`}>
<Avatar className="h-8 w-8 hover:ring-2 hover:ring-primary/30 transition-all cursor-pointer">
<AvatarImage src={metadata?.picture} />
<AvatarFallback className="text-xs">
{displayName.charAt(0)}
</AvatarFallback>
</Avatar>
</Link>
<div>
<Link
to={`/${nip19.npubEncode(comment.pubkey)}`}
className="font-medium text-sm hover:text-primary transition-colors"
>
{displayName}
</Link>
<p className="text-xs text-muted-foreground">{timeAgo}</p>
</div>
</div>
</div>
{/* Comment Content */}
<div className="text-sm">
<NoteContent event={comment} className="text-sm" />
</div>
{/* Comment Actions */}
<div className="flex items-center justify-between pt-2">
<div className="flex items-center space-x-2">
<Button
variant="ghost"
size="sm"
onClick={() => setShowReplyForm(!showReplyForm)}
className="h-8 px-2 text-xs"
>
<MessageSquare className="h-3 w-3 mr-1" />
Reply
</Button>
{hasReplies && (
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
<CollapsibleTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 px-2 text-xs">
{showReplies ? (
<ChevronDown className="h-3 w-3 mr-1" />
) : (
<ChevronRight className="h-3 w-3 mr-1" />
)}
{replies.length} {replies.length === 1 ? 'reply' : 'replies'}
</Button>
</CollapsibleTrigger>
</Collapsible>
)}
</div>
{/* Comment menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-xs"
aria-label="Comment options"
>
<MoreHorizontal className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
</DropdownMenu>
</div>
</div>
</CardContent>
</Card>
{/* Reply Form */}
{showReplyForm && (
<div className="ml-6">
<CommentForm
root={root}
reply={comment}
onSuccess={() => setShowReplyForm(false)}
placeholder="Write a reply..."
compact
/>
</div>
)}
{/* Replies */}
{hasReplies && (
<Collapsible open={showReplies} onOpenChange={setShowReplies}>
<CollapsibleContent className="space-y-3">
{replies.map((reply) => (
<Comment
key={reply.id}
root={root}
comment={reply}
depth={depth + 1}
maxDepth={maxDepth}
limit={limit}
/>
))}
</CollapsibleContent>
</Collapsible>
)}
</div>
);
}

View File

@ -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 (
<Card className={compact ? "border-dashed" : ""}>
<CardContent className={compact ? "p-4" : "p-6"}>
<div className="text-center space-y-4">
<div className="flex items-center justify-center space-x-2 text-muted-foreground">
<MessageSquare className="h-5 w-5" />
<span>Sign in to {reply ? 'reply' : 'comment'}</span>
</div>
<LoginArea />
</div>
</CardContent>
</Card>
);
}
return (
<Card className={compact ? "border-dashed" : ""}>
<CardContent className={compact ? "p-4" : "p-6"}>
<form onSubmit={handleSubmit} className="space-y-4">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder={placeholder}
className={compact ? "min-h-[80px]" : "min-h-[100px]"}
disabled={isPending}
/>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">
{reply ? 'Replying to comment' : 'Adding to the discussion'}
</span>
<Button
type="submit"
disabled={!content.trim() || isPending}
size={compact ? "sm" : "default"}
>
<Send className="h-4 w-4 mr-2" />
{isPending ? 'Posting...' : (reply ? 'Reply' : 'Comment')}
</Button>
</div>
</form>
</CardContent>
</Card>
);
}

View File

@ -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 (
<Card className="rounded-none sm:rounded-lg mx-0 sm:mx-0">
<CardContent className="px-2 py-6 sm:p-6">
<div className="text-center text-muted-foreground">
<MessageSquare className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p>Failed to load comments</p>
</div>
</CardContent>
</Card>
);
}
return (
<Card className={cn("rounded-none sm:rounded-lg mx-0 sm:mx-0", className)}>
<CardHeader className="px-2 pt-6 pb-4 sm:p-6">
<CardTitle className="flex items-center space-x-2">
<MessageSquare className="h-5 w-5" />
<span>{title}</span>
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground">
({comments.length})
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="px-2 pb-6 pt-4 sm:p-6 sm:pt-0 space-y-6">
{/* Comment Form */}
<CommentForm root={root} />
{/* Comments List */}
{isLoading ? (
<div className="space-y-4">
{[...Array(3)].map((_, i) => (
<Card key={i} className="bg-card/50">
<CardContent className="p-4">
<div className="space-y-3">
<div className="flex items-center space-x-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-1">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-3 w-16" />
</div>
</div>
<Skeleton className="h-16 w-full" />
</div>
</CardContent>
</Card>
))}
</div>
) : comments.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium mb-2">{emptyStateMessage}</p>
<p className="text-sm">{emptyStateSubtitle}</p>
</div>
) : (
<div className="space-y-4">
{comments.map((comment) => (
<Comment
key={comment.id}
root={root}
comment={comment}
/>
))}
</div>
)}
</CardContent>
</Card>
);
}

98
src/hooks/useComments.ts Normal file
View File

@ -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<string, NostrEvent[]>();
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,
});
}

View File

@ -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]
});
},
});
}