Remove dead CommentsSection component and rewrite nostr-comments skill
Delete src/components/comments/ (CommentsSection, CommentForm, Comment) which were never imported by any page or component. Rewrite the nostr-comments SKILL.md to document the actual commenting architecture: ComposeBox, ThreadedReplyList, useComments, usePostComment, and the dual NIP-10/NIP-22 protocol system used across PostDetailPage, ExternalContentPage, and other pages.
This commit is contained in:
@@ -3,170 +3,229 @@ name: nostr-comments
|
||||
description: Implement Nostr comment systems, add discussion features to posts/articles, build community interaction features, or attach comments to any external content identifier including URLs, hashtags, and NIP-73 identifiers (ISBN, podcast GUIDs, geohashes, movie ISANs, blockchain transactions, and more).
|
||||
---
|
||||
|
||||
# Adding Nostr Comments Sections
|
||||
# Adding Nostr Comments
|
||||
|
||||
The project includes a complete commenting system using NIP-22 (kind 1111) comments that can be added to any Nostr event, URL, hashtag, or NIP-73 external content identifier. The `CommentsSection` component provides a full-featured commenting interface with threaded replies, user authentication, and real-time updates.
|
||||
The project has a commenting system built on two Nostr protocols:
|
||||
|
||||
## Basic Usage
|
||||
- **NIP-10 replies** (kind 1 → kind 1): For replying to kind 1 text notes. Published as kind 1 events with `e`-tag markers (`root`/`reply`).
|
||||
- **NIP-22 comments** (kind 1111): For commenting on everything else — non-kind-1 events, external URLs, NIP-73 identifiers, hashtags. Uses uppercase tags for root references and lowercase tags for the immediate parent.
|
||||
|
||||
Voice equivalents also exist: kind 1222 (NIP-10 voice replies) and kind 1244 (NIP-22 voice comments).
|
||||
|
||||
## Architecture
|
||||
|
||||
The commenting system is composed of page-level logic, shared hooks, and shared UI components. There is no single drop-in `<CommentsSection>` component. Instead, each page assembles commenting from these building blocks:
|
||||
|
||||
### Hooks
|
||||
|
||||
| Hook | File | Purpose |
|
||||
|------|------|---------|
|
||||
| `useComments` | `src/hooks/useComments.ts` | Fetches NIP-22 comments (kind 1111 + 1244) for a root. Returns `topLevelComments`, `getDirectReplies(id)`, and `getDescendants(id)`. |
|
||||
| `usePostComment` | `src/hooks/usePostComment.ts` | Publishes NIP-22 kind 1111 comments with correct uppercase/lowercase tag structure. |
|
||||
| `useReplies` | `src/hooks/useReplies.ts` | Fetches NIP-10 replies (kind 1) to a given event ID. |
|
||||
| `useWallComments` | `src/hooks/useWallComments.ts` | Fetches comments on a user's kind 0 profile event (wall comments). |
|
||||
|
||||
### UI Components
|
||||
|
||||
| Component | File | Purpose |
|
||||
|-----------|------|---------|
|
||||
| `ComposeBox` | `src/components/ComposeBox.tsx` | The universal compose input. Handles both NIP-10 and NIP-22 modes based on its `replyTo` prop. |
|
||||
| `ReplyComposeModal` | `src/components/ReplyComposeModal.tsx` | Dialog wrapper around `ComposeBox` for modal reply/comment composition. |
|
||||
| `ThreadedReplyList` | `src/components/ThreadedReplyList.tsx` | Renders a flat list of replies, each optionally paired with its first sub-reply for visual threading. |
|
||||
| `NoteCard` | `src/components/NoteCard.tsx` | Renders individual events with `threaded`/`threadedLast` props for connector-line styling. |
|
||||
|
||||
## How `ComposeBox` Determines the Protocol
|
||||
|
||||
The `replyTo` prop controls which protocol is used:
|
||||
|
||||
```tsx
|
||||
import { CommentsSection } from "@/components/comments/CommentsSection";
|
||||
// Inside ComposeBox:
|
||||
const isNip22Reply = replyTo && (replyTo instanceof URL || replyTo.kind !== 1);
|
||||
```
|
||||
|
||||
| `replyTo` value | Protocol | Published kind |
|
||||
|---|---|---|
|
||||
| `undefined` | New post | kind 1 |
|
||||
| Kind 1 `NostrEvent` | NIP-10 | kind 1 (with `e`-tag markers) |
|
||||
| Non-kind-1 `NostrEvent` | NIP-22 | kind 1111 (via `usePostComment`) |
|
||||
| Kind 1111 `NostrEvent` | NIP-22 | kind 1111 (reconstructs root from uppercase tags) |
|
||||
| `URL` | NIP-22 | kind 1111 (via `usePostComment`) |
|
||||
|
||||
## Adding Comments to a Page
|
||||
|
||||
### For Nostr Events (non-kind-1)
|
||||
|
||||
Use `useComments` to fetch comments and `ComposeBox` or `ReplyComposeModal` with the event as `replyTo`:
|
||||
|
||||
```tsx
|
||||
import { useMemo } from 'react';
|
||||
import type { NostrEvent } from '@nostrify/nostrify';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { ThreadedReplyList, type ThreadedReply } from '@/components/ThreadedReplyList';
|
||||
|
||||
function ArticleComments({ article }: { article: NostrEvent }) {
|
||||
const { data: commentsData, isLoading } = useComments(article, 500);
|
||||
|
||||
const orderedReplies: ThreadedReply[] = useMemo(() => {
|
||||
if (!commentsData) return [];
|
||||
return commentsData.topLevelComments
|
||||
.sort((a, b) => a.created_at - b.created_at)
|
||||
.map((reply) => ({
|
||||
reply,
|
||||
firstSubReply: commentsData.getDirectReplies(reply.id)[0],
|
||||
}));
|
||||
}, [commentsData]);
|
||||
|
||||
function ArticlePage({ article }: { article: NostrEvent }) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Your article content */}
|
||||
<div>{/* article content */}</div>
|
||||
|
||||
{/* Comments section */}
|
||||
<CommentsSection root={article} />
|
||||
<div>
|
||||
<ComposeBox compact replyTo={article} />
|
||||
<ThreadedReplyList replies={orderedReplies} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Props and Customization
|
||||
### For External Content (URLs and NIP-73 Identifiers)
|
||||
|
||||
The `CommentsSection` component accepts the following props:
|
||||
|
||||
- **`root`** (required): The root to comment on. Accepts three types:
|
||||
- `NostrEvent` — comment on a Nostr event (kind 1 note, long-form article, etc.)
|
||||
- `URL` — comment on an external identifier: web URLs (`new URL("https://...")`) or any NIP-73 identifier except hashtags (e.g. `new URL("isbn:9780765382030")`, `new URL("iso3166:US")`)
|
||||
- `#${string}` — NIP-73 hashtag only (e.g. `"#bitcoin"`); this template string type is exclusively for hashtags and must not be used for other NIP-73 identifiers
|
||||
- **`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)
|
||||
Pass a `URL` object as `replyTo` to `ComposeBox` and as `root` to `useComments`:
|
||||
|
||||
```tsx
|
||||
<CommentsSection
|
||||
root={event}
|
||||
title="Discussion"
|
||||
emptyStateMessage="Start the conversation"
|
||||
emptyStateSubtitle="Share your thoughts about this post"
|
||||
className="mt-8"
|
||||
limit={100}
|
||||
/>
|
||||
import { useMemo } from 'react';
|
||||
import { useComments } from '@/hooks/useComments';
|
||||
import { ComposeBox } from '@/components/ComposeBox';
|
||||
import { ThreadedReplyList, type ThreadedReply } from '@/components/ThreadedReplyList';
|
||||
|
||||
function ExternalComments({ identifier }: { identifier: string }) {
|
||||
const commentRoot = useMemo(() => new URL(identifier), [identifier]);
|
||||
const { data: commentsData } = useComments(commentRoot, 500);
|
||||
|
||||
const orderedReplies: ThreadedReply[] = useMemo(() => {
|
||||
if (!commentsData) return [];
|
||||
return commentsData.topLevelComments
|
||||
.sort((a, b) => a.created_at - b.created_at)
|
||||
.map((reply) => ({
|
||||
reply,
|
||||
firstSubReply: commentsData.getDirectReplies(reply.id)[0],
|
||||
}));
|
||||
}, [commentsData]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ComposeBox compact replyTo={commentRoot} />
|
||||
<ThreadedReplyList replies={orderedReplies} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Commenting on URLs
|
||||
### Using the Modal Composer
|
||||
|
||||
The comments system supports commenting on external URLs, making it useful for web pages, articles, or any online content:
|
||||
For a modal-based compose experience (e.g. triggered by a FAB or reply button):
|
||||
|
||||
```tsx
|
||||
<CommentsSection
|
||||
root={new URL("https://example.com/article")}
|
||||
title="Comments on this article"
|
||||
/>
|
||||
import { useState } from 'react';
|
||||
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
function CommentButton({ target }: { target: NostrEvent | URL }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Comment</Button>
|
||||
<ReplyComposeModal event={target} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Commenting on Hashtags
|
||||
`ReplyComposeModal` accepts `event` as either a `NostrEvent` or `URL`, which it passes through to `ComposeBox` as `replyTo`.
|
||||
|
||||
Pass a hashtag string (`#${string}` format) to attach comments to a topic. The hashtag must be lowercase:
|
||||
## Supported Root Types for `useComments`
|
||||
|
||||
The `useComments` hook accepts three root types:
|
||||
|
||||
- **`NostrEvent`** — comments on any Nostr event (addressable, replaceable, or regular)
|
||||
- **`URL`** — comments on external identifiers: web URLs (`new URL("https://...")`) or any NIP-73 identifier except hashtags (e.g. `new URL("isbn:9780765382030")`, `new URL("iso3166:US")`)
|
||||
- **`` `#${string}` ``** — NIP-73 hashtag only (e.g. `"#bitcoin"`); this template string type is exclusively for hashtags
|
||||
|
||||
## NIP-73 External Content Identifiers
|
||||
|
||||
All NIP-73 identifiers (except hashtags) are passed as `URL` objects:
|
||||
|
||||
```tsx
|
||||
// Comments for the #bitcoin hashtag
|
||||
<CommentsSection
|
||||
root="#bitcoin"
|
||||
title="Bitcoin Discussion"
|
||||
/>
|
||||
// Web URL
|
||||
const root = new URL("https://example.com/article");
|
||||
|
||||
// Comments for a community-specific tag
|
||||
<CommentsSection
|
||||
root="#nostr"
|
||||
title="Nostr Community"
|
||||
/>
|
||||
```
|
||||
// Book (ISBN, without hyphens)
|
||||
const root = new URL("isbn:9780765382030");
|
||||
|
||||
## Commenting on NIP-73 External Content Identifiers
|
||||
|
||||
NIP-73 defines a standard set of external content IDs. All NIP-73 identifiers (except hashtags) are passed as `URL` objects — the identifier string is used directly as the URL:
|
||||
|
||||
### Books (ISBN)
|
||||
|
||||
```tsx
|
||||
// ISBN must be without hyphens
|
||||
<CommentsSection
|
||||
root={new URL("isbn:9780765382030")}
|
||||
title="Book Discussion"
|
||||
/>
|
||||
```
|
||||
|
||||
### Podcasts
|
||||
|
||||
```tsx
|
||||
// Podcast feed
|
||||
<CommentsSection
|
||||
root={new URL("podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc")}
|
||||
title="Podcast Discussion"
|
||||
/>
|
||||
const root = new URL("podcast:guid:c90e609a-df1e-596a-bd5e-57bcc8aad6cc");
|
||||
|
||||
// Podcast episode
|
||||
<CommentsSection
|
||||
root={new URL("podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f")}
|
||||
title="Episode Discussion"
|
||||
/>
|
||||
```
|
||||
const root = new URL("podcast:item:guid:d98d189b-dc7b-45b1-8720-d4b98690f31f");
|
||||
|
||||
### Movies (ISAN)
|
||||
// Movie (ISAN, without version part)
|
||||
const root = new URL("isan:0000-0000-401A-0000-7");
|
||||
|
||||
```tsx
|
||||
// ISAN without version part
|
||||
<CommentsSection
|
||||
root={new URL("isan:0000-0000-401A-0000-7")}
|
||||
title="Movie Discussion"
|
||||
/>
|
||||
```
|
||||
// Geohash (lowercase)
|
||||
const root = new URL("geo:ezs42e44yx96");
|
||||
|
||||
### Geohashes
|
||||
// Country (ISO 3166, uppercase)
|
||||
const root = new URL("iso3166:US");
|
||||
|
||||
```tsx
|
||||
// Geohash must be lowercase
|
||||
<CommentsSection
|
||||
root={new URL("geo:ezs42e44yx96")}
|
||||
title="Location Discussion"
|
||||
/>
|
||||
```
|
||||
// Subdivision
|
||||
const root = new URL("iso3166:US-CA");
|
||||
|
||||
### Countries (ISO 3166)
|
||||
// Academic paper (DOI, lowercase)
|
||||
const root = new URL("doi:10.1000/xyz123");
|
||||
|
||||
```tsx
|
||||
// ISO 3166 codes must be uppercase
|
||||
<CommentsSection
|
||||
root={new URL("iso3166:US")}
|
||||
title="USA Discussion"
|
||||
/>
|
||||
|
||||
// Subdivision (state/province)
|
||||
<CommentsSection
|
||||
root={new URL("iso3166:US-CA")}
|
||||
title="California Discussion"
|
||||
/>
|
||||
```
|
||||
|
||||
### Academic Papers (DOI)
|
||||
|
||||
```tsx
|
||||
// DOI must be lowercase
|
||||
<CommentsSection
|
||||
root={new URL("doi:10.1000/xyz123")}
|
||||
title="Paper Discussion"
|
||||
/>
|
||||
```
|
||||
|
||||
### Blockchain Transactions and Addresses
|
||||
|
||||
```tsx
|
||||
// Bitcoin transaction
|
||||
<CommentsSection
|
||||
root={new URL("bitcoin:tx:a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d")}
|
||||
title="Transaction Discussion"
|
||||
/>
|
||||
const root = new URL("bitcoin:tx:a1075db55d416d3ca199f55b6084e2115b9345e16c5cf302fc80e9d5fbf5d48d");
|
||||
|
||||
// Ethereum address
|
||||
<CommentsSection
|
||||
root={new URL("ethereum:1:address:0xd8da6bf26964af9d7eed9e03e53415d37aa96045")}
|
||||
title="Address Discussion"
|
||||
/>
|
||||
const root = new URL("ethereum:1:address:0xd8da6bf26964af9d7eed9e03e53415d37aa96045");
|
||||
```
|
||||
|
||||
Hashtags use the string format directly:
|
||||
|
||||
```tsx
|
||||
const root = "#bitcoin";
|
||||
```
|
||||
|
||||
## How `useComments` Builds Filters
|
||||
|
||||
The hook constructs relay filters based on the root type:
|
||||
|
||||
| Root type | Filter |
|
||||
|---|---|
|
||||
| Hashtag string | `{ kinds: [1111, 1244], '#I': [root] }` |
|
||||
| `URL` | `{ kinds: [1111, 1244], '#I': [root.toString()] }` |
|
||||
| Addressable event | `{ kinds: [1111, 1244], '#A': ["kind:pubkey:d"] }` |
|
||||
| Replaceable event | `{ kinds: [1111, 1244], '#A': ["kind:pubkey:"] }` |
|
||||
| Regular event | `{ kinds: [1111, 1244], '#E': [root.id] }` |
|
||||
|
||||
## How `usePostComment` Builds Tags
|
||||
|
||||
The hook constructs NIP-22 tags with uppercase for the root scope and lowercase for the reply scope:
|
||||
|
||||
- `E`/`e` — Event ID reference
|
||||
- `A`/`a` — Addressable event coordinate (`kind:pubkey:d-tag`)
|
||||
- `I`/`i` — External identifier (URL, hashtag)
|
||||
- `K`/`k` — Kind number, or `web` for HTTP(S), or `#` for hashtags
|
||||
- `P`/`p` — Pubkey of the referenced event's author
|
||||
|
||||
For top-level comments, both the root and reply tags point to the same target. For nested replies, the root tags point to the original root and the reply tags point to the parent comment.
|
||||
|
||||
## Where Comments Are Used in the App
|
||||
|
||||
| Page | File | Commenting mode |
|
||||
|------|------|-----------------|
|
||||
| `PostDetailPage` (kind 1) | `src/pages/PostDetailPage.tsx` | NIP-10 via `useReplies` |
|
||||
| `PostDetailPage` (non-kind-1) | `src/pages/PostDetailPage.tsx` | NIP-22 via `useComments` |
|
||||
| `PostDetailPage` (kind 1111) | `src/pages/PostDetailPage.tsx` | NIP-22, reconstructs root from uppercase tags |
|
||||
| `ExternalContentPage` | `src/pages/ExternalContentPage.tsx` | NIP-22 via `useComments` with `URL` root |
|
||||
| `VinesFeedPage` | `src/pages/VinesFeedPage.tsx` | NIP-22 via `CommentsSheet` + `useEventComments` |
|
||||
| `ProfilePage` (wall) | `src/pages/ProfilePage.tsx` | NIP-22 via `useWallComments` on kind 0 |
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
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 | `#${string}`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
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 | `#${string}`;
|
||||
reply?: NostrEvent | URL | `#${string}`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
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 | `#${string}`;
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user