mirror of
https://gitlab.com/soapbox-pub/mkstack.git
synced 2025-08-26 20:49:22 +00:00
merge in from upstream and resolve conflicts
This commit is contained in:
commit
ab54efba47
@ -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
|
||||
|
54
CONTEXT.md
54
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 (
|
||||
<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
|
||||
|
||||
|
30
package-lock.json
generated
30
package-lock.json
generated
@ -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"
|
||||
}
|
||||
},
|
||||
|
@ -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",
|
||||
|
153
src/components/comments/Comment.tsx
Normal file
153
src/components/comments/Comment.tsx
Normal 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>
|
||||
);
|
||||
}
|
90
src/components/comments/CommentForm.tsx
Normal file
90
src/components/comments/CommentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
100
src/components/comments/CommentsSection.tsx
Normal file
100
src/components/comments/CommentsSection.tsx
Normal 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
98
src/hooks/useComments.ts
Normal 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,
|
||||
});
|
||||
}
|
92
src/hooks/usePostComment.ts
Normal file
92
src/hooks/usePostComment.ts
Normal 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]
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user