DRY up wall and reply rendering with ThreadedReplyList component

Extract shared ThreadedReplyList component and use it in PostDetailPage,
ExternalContentPage, and ProfilePage wall tab. Wall comments now also fetch
sub-replies via useComments so they render with the same threaded/threadedLast
connector-line pairing as replies.
This commit is contained in:
Chad Curtis
2026-03-01 04:01:01 -06:00
parent bc08f6d7de
commit 525317fe49
4 changed files with 53 additions and 21 deletions
+31
View File
@@ -0,0 +1,31 @@
import type { NostrEvent } from '@nostrify/nostrify';
import { NoteCard } from '@/components/NoteCard';
export interface ThreadedReply {
reply: NostrEvent;
firstSubReply?: NostrEvent;
}
interface ThreadedReplyListProps {
replies: ThreadedReply[];
}
/**
* Renders a flat list of replies where each top-level reply is optionally
* followed by its first sub-reply, using NoteCard's threaded/threadedLast
* connector-line styling for visual continuity.
*/
export function ThreadedReplyList({ replies }: ThreadedReplyListProps) {
return (
<>
{replies.map(({ reply, firstSubReply }) => (
<div key={reply.id}>
<NoteCard event={reply} threaded={!!firstSubReply} />
{firstSubReply && (
<NoteCard event={firstSubReply} threadedLast />
)}
</div>
))}
</>
);
}
+2 -9
View File
@@ -4,7 +4,7 @@ import { ArrowLeft, Globe, Heart, MessageSquare, Repeat2 } from 'lucide-react';
import { Link, useLocation, useParams } from 'react-router-dom';
import { Skeleton } from '@/components/ui/skeleton';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { NoteCard } from '@/components/NoteCard';
import { ThreadedReplyList } from '@/components/ThreadedReplyList';
import { ComposeBox } from '@/components/ComposeBox';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { QuickReactMenu } from '@/components/QuickReactMenu';
@@ -326,14 +326,7 @@ export function ExternalContentPage() {
))}
</div>
) : orderedReplies.length > 0 ? (
orderedReplies.map(({ reply, firstSubReply }) => (
<div key={reply.id}>
<NoteCard event={reply} threaded={!!firstSubReply} />
{firstSubReply && (
<NoteCard event={firstSubReply} threadedLast />
)}
</div>
))
<ThreadedReplyList replies={orderedReplies} />
) : (
<div className="py-12 text-center text-muted-foreground text-sm">
<MessageSquare className="size-12 mx-auto mb-4 opacity-30" />
+2 -8
View File
@@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input';
import { NoteContent } from '@/components/NoteContent';
import { VideoPlayer } from '@/components/VideoPlayer';
import { NoteCard } from '@/components/NoteCard';
import { ThreadedReplyList } from '@/components/ThreadedReplyList';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
import { ReactionButton } from '@/components/ReactionButton';
@@ -1070,14 +1071,7 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
))}
</div>
) : orderedReplies.length > 0 ? (
orderedReplies.map(({ reply, firstSubReply }) => (
<div key={reply.id}>
<NoteCard event={reply} threaded={!!firstSubReply} />
{firstSubReply && (
<NoteCard event={firstSubReply} threadedLast />
)}
</div>
))
<ThreadedReplyList replies={orderedReplies} />
) : !parentEventId ? (
<div className="py-12 text-center text-muted-foreground text-sm">
No replies yet. Be the first to reply!
+18 -4
View File
@@ -37,6 +37,8 @@ import type { ProfileTab } from '@/hooks/useProfileFeed';
import { useProfileMedia } from '@/hooks/useProfileMedia';
import { useProfileSupplementary } from '@/hooks/useProfileData';
import { useWallComments } from '@/hooks/useWallComments';
import { useComments } from '@/hooks/useComments';
import { ThreadedReplyList } from '@/components/ThreadedReplyList';
import { useNip05Resolve } from '@/hooks/useNip05Resolve';
import { genUserName } from '@/lib/genUserName';
@@ -745,6 +747,9 @@ export function ProfilePage() {
};
}, [pubkey, metadataEvent]);
// Fetch all NIP-22 comments on the profile (for sub-reply pairing in the wall tab)
const { data: wallCommentsData } = useComments(wallReplyTarget, 500);
// Wall compose modal state (for FAB on wall tab)
const [wallComposeOpen, setWallComposeOpen] = useState(false);
@@ -1032,6 +1037,17 @@ export function ProfilePage() {
return items;
}, [wallData?.pages, muteItems]);
// Pair each wall comment with its first direct sub-reply (same pattern as PostDetailPage replies)
const orderedWallReplies = useMemo(() => {
return wallComments.map((comment) => {
const subReplies = wallCommentsData?.getDirectReplies(comment.id) ?? [];
return {
reply: comment,
firstSubReply: subReplies[0] as NostrEvent | undefined,
};
});
}, [wallComments, wallCommentsData]);
const streak = useMemo(() => {
if (!feedData?.pages) return 0;
const events: NostrEvent[] = [];
@@ -1545,11 +1561,9 @@ export function ProfilePage() {
</div>
))}
</div>
) : wallComments.length > 0 ? (
) : orderedWallReplies.length > 0 ? (
<div>
{wallComments.map((comment) => (
<NoteCard key={comment.id} event={comment} />
))}
<ThreadedReplyList replies={orderedWallReplies} />
{/* Infinite scroll sentinel */}
{hasNextWallPage && (