Add nested thread trees, recursive reply fetching, multi-level back navigation

Overhauls the thread view from flat single-level replies to proper nested
conversation trees. Fixes NIP-10 tagging (root + reply markers), adds
2-round-trip recursive thread fetch, ancestor chain display, reply-to-any-note
targeting, view stack navigation (up to 20 levels), and loading shimmer.
This commit is contained in:
Jure
2026-03-21 15:21:46 +01:00
parent 5a8250e7cf
commit acb0d531c0
10 changed files with 578 additions and 236 deletions

View File

@@ -7,9 +7,10 @@ import { EmojiPicker } from "../shared/EmojiPicker";
interface InlineReplyBoxProps {
event: NDKEvent;
name: string;
rootEvent?: { id: string; pubkey: string };
}
export function InlineReplyBox({ event, name }: InlineReplyBoxProps) {
export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {
const [replyText, setReplyText] = useState("");
const [replying, setReplying] = useState(false);
const [replyError, setReplyError] = useState<string | null>(null);
@@ -23,7 +24,7 @@ export function InlineReplyBox({ event, name }: InlineReplyBoxProps) {
setReplying(true);
setReplyError(null);
try {
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey }, rootEvent);
setReplyText("");
setReplySent(true);
adjustReplyCount(1);

View File

@@ -7,6 +7,7 @@ import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui";
import { timeAgo, shortenPubkey } from "../../lib/utils";
import { getNDK, fetchNoteById } from "../../lib/nostr";
import { getParentEventId } from "../../lib/threadTree";
import { NoteContent } from "./NoteContent";
import { NoteActions, LoggedOutStats } from "./NoteActions";
import { InlineReplyBox } from "./InlineReplyBox";
@@ -14,14 +15,7 @@ import { InlineReplyBox } from "./InlineReplyBox";
interface NoteCardProps {
event: NDKEvent;
focused?: boolean;
}
function getParentEventId(event: NDKEvent): string | null {
const eTags = event.tags.filter((t) => t[0] === "e");
if (eTags.length === 0) return null;
return eTags.find((t) => t[3] === "reply")?.[1]
?? eTags.find((t) => t[3] === "root")?.[1]
?? eTags[eTags.length - 1][1];
onReplyInThread?: (event: NDKEvent) => void;
}
function ParentAuthorName({ pubkey }: { pubkey: string }) {
@@ -30,7 +24,7 @@ function ParentAuthorName({ pubkey }: { pubkey: string }) {
return <span className="text-accent">@{name}</span>;
}
export function NoteCard({ event, focused }: NoteCardProps) {
export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
@@ -57,6 +51,7 @@ export function NoteCard({ event, focused }: NoteCardProps) {
return (
<article
ref={cardRef}
data-note-id={event.id}
className={`border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100 group/card${focused ? " ring-1 ring-inset ring-accent/30" : ""}`}
>
<div className="flex gap-3">
@@ -160,8 +155,14 @@ export function NoteCard({ event, focused }: NoteCardProps) {
{loggedIn && !!getNDK().signer && (
<NoteActions
event={event}
onReplyToggle={() => setShowReply((v) => !v)}
showReply={showReply}
onReplyToggle={() => {
if (onReplyInThread) {
onReplyInThread(event);
} else {
setShowReply((v) => !v);
}
}}
showReply={showReply && !onReplyInThread}
/>
)}

View File

@@ -0,0 +1,60 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useProfile } from "../../hooks/useProfile";
import { useUIStore } from "../../stores/ui";
import { shortenPubkey, timeAgo } from "../../lib/utils";
function AncestorCard({ event }: { event: NDKEvent }) {
const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
const time = event.created_at ? timeAgo(event.created_at) : "";
const { openThread } = useUIStore();
const truncated = event.content.length > 120
? event.content.slice(0, 120) + "..."
: event.content;
return (
<button
onClick={() => openThread(event)}
className="w-full text-left px-4 py-2 border-b border-border hover:bg-bg-hover transition-colors flex gap-2.5 items-start"
>
<div className="shrink-0 mt-0.5">
{avatar ? (
<img src={avatar} alt="" className="w-6 h-6 rounded-sm object-cover bg-bg-raised" loading="lazy" />
) : (
<div className="w-6 h-6 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-[9px]">
{name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline gap-2 mb-0.5">
<span className="text-text font-medium text-[11px] truncate">{name}</span>
<span className="text-text-dim text-[10px] shrink-0">{time}</span>
</div>
<div className="text-text-dim text-[11px] line-clamp-2 break-words">{truncated}</div>
</div>
<div className="text-text-dim text-[10px] shrink-0 mt-0.5"></div>
</button>
);
}
interface AncestorChainProps {
ancestors: NDKEvent[];
}
export function AncestorChain({ ancestors }: AncestorChainProps) {
if (ancestors.length === 0) return null;
return (
<div className="bg-bg-raised/50">
<div className="px-4 py-1.5 text-text-dim text-[10px] border-b border-border">
{ancestors.length} parent {ancestors.length === 1 ? "note" : "notes"} above
</div>
{ancestors.map((a) => (
<AncestorCard key={a.id} event={a} />
))}
</div>
);
}

View File

@@ -0,0 +1,75 @@
import { useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk";
import type { ThreadNode as ThreadNodeType } from "../../lib/threadTree";
import { NoteCard } from "../feed/NoteCard";
interface ThreadNodeProps {
node: ThreadNodeType;
onReplyInThread: (event: NDKEvent) => void;
focusedId?: string;
mutedPubkeys: string[];
contentMatchesMutedKeyword: (content: string) => boolean;
}
const MAX_VISIBLE_CHILDREN = 3;
const MAX_INDENT_DEPTH = 4;
export function ThreadNodeComponent({ node, onReplyInThread, focusedId, mutedPubkeys, contentMatchesMutedKeyword }: ThreadNodeProps) {
const [expanded, setExpanded] = useState(false);
// Filter out muted children
const visibleChildren = node.children.filter(
(c) => !mutedPubkeys.includes(c.event.pubkey) && !contentMatchesMutedKeyword(c.event.content)
);
const hiddenCount = node.children.length - visibleChildren.length;
const shouldCollapse = visibleChildren.length > MAX_VISIBLE_CHILDREN && !expanded;
const shownChildren = shouldCollapse ? visibleChildren.slice(0, 2) : visibleChildren;
const remainingCount = visibleChildren.length - shownChildren.length;
const indent = Math.min(node.depth, MAX_INDENT_DEPTH);
const isFocused = node.event.id === focusedId;
return (
<div
className={indent > 0 ? "border-l-2 border-border" : ""}
style={indent > 0 ? { marginLeft: `${indent * 16}px` } : undefined}
>
<NoteCard
event={node.event}
focused={isFocused}
onReplyInThread={onReplyInThread}
/>
{shownChildren.map((child) => (
<ThreadNodeComponent
key={child.event.id}
node={child}
onReplyInThread={onReplyInThread}
focusedId={focusedId}
mutedPubkeys={mutedPubkeys}
contentMatchesMutedKeyword={contentMatchesMutedKeyword}
/>
))}
{remainingCount > 0 && (
<button
onClick={() => setExpanded(true)}
className="text-accent hover:text-accent-hover text-[11px] py-1.5 transition-colors"
style={{ marginLeft: `${(indent + 1) * 16}px` }}
>
show {remainingCount} more {remainingCount === 1 ? "reply" : "replies"}
</button>
)}
{hiddenCount > 0 && (
<div
className="text-text-dim text-[10px] py-1 italic"
style={{ marginLeft: `${(indent + 1) * 16}px` }}
>
{hiddenCount} {hiddenCount === 1 ? "reply" : "replies"} hidden (muted)
</div>
)}
</div>
);
}

View File

@@ -3,143 +3,24 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useUIStore } from "../../stores/ui";
import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute";
import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, getNDK } from "../../lib/nostr";
import { buildThreadTree, getRootEventId } from "../../lib/threadTree";
import type { ThreadNode } from "../../lib/threadTree";
import { useProfile } from "../../hooks/useProfile";
import { useReactionCount } from "../../hooks/useReactionCount";
import { useZapCount } from "../../hooks/useZapCount";
import { fetchReplies, publishReaction, publishReply, publishRepost, getNDK } from "../../lib/nostr";
import { QuoteModal } from "../feed/QuoteModal";
import { EmojiPicker } from "../shared/EmojiPicker";
import { shortenPubkey, timeAgo } from "../../lib/utils";
import { NoteContent } from "../feed/NoteContent";
import { shortenPubkey } from "../../lib/utils";
import { AncestorChain } from "./AncestorChain";
import { ThreadNodeComponent } from "./ThreadNode";
import { NoteCard } from "../feed/NoteCard";
import { ZapModal } from "../zap/ZapModal";
import { EmojiPicker } from "../shared/EmojiPicker";
function RootNote({ event }: { event: NDKEvent }) {
const { openProfile } = useUIStore();
const { loggedIn } = useUserStore();
function ReplyTargetBadge({ event, onClear }: { event: NDKEvent; onClear: () => void }) {
const profile = useProfile(event.pubkey);
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
const avatar = profile?.picture;
const nip05 = profile?.nip05;
const time = event.created_at ? timeAgo(event.created_at) : "";
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
const zapData = useZapCount(event.id);
const [liked, setLiked] = useState(() => {
try { return new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")).has(event.id); }
catch { return false; }
});
const [liking, setLiking] = useState(false);
const [showZap, setShowZap] = useState(false);
const [reposting, setReposting] = useState(false);
const [reposted, setReposted] = useState(false);
const [showQuote, setShowQuote] = useState(false);
const hasLightning = !!(profile?.lud16 || profile?.lud06);
const handleLike = async () => {
if (!loggedIn || liked || liking) return;
setLiking(true);
try {
await publishReaction(event.id, event.pubkey);
const likedSet = new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]"));
likedSet.add(event.id);
localStorage.setItem("wrystr_liked", JSON.stringify(Array.from(likedSet)));
setLiked(true);
adjustReactionCount(1);
} finally {
setLiking(false);
}
};
const handleRepost = async () => {
if (reposting || reposted) return;
setReposting(true);
try {
await publishRepost(event);
setReposted(true);
} finally {
setReposting(false);
}
};
return (
<div className="px-4 py-4 border-b border-border">
<div className="flex gap-3 mb-3">
<div className="shrink-0 cursor-pointer" onClick={() => openProfile(event.pubkey)}>
{avatar ? (
<img src={avatar} alt="" className="w-10 h-10 rounded-sm object-cover bg-bg-raised hover:opacity-80 transition-opacity" />
) : (
<div className="w-10 h-10 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-sm">
{name.charAt(0).toUpperCase()}
</div>
)}
</div>
<div>
<span
className="text-text font-medium text-[13px] cursor-pointer hover:text-accent transition-colors"
onClick={() => openProfile(event.pubkey)}
>{name}</span>
{nip05 && <div className="text-text-dim text-[10px]">{nip05}</div>}
</div>
</div>
<NoteContent content={event.content} />
<div className="text-text-dim text-[10px] mt-3">{time}</div>
{/* Action row */}
{loggedIn && !!getNDK().signer && (
<div className="flex items-center gap-4 mt-3">
<button
onClick={handleLike}
disabled={liked || liking}
className={`text-[11px] transition-colors ${
liked ? "text-accent" : "text-text-dim hover:text-accent"
} disabled:cursor-default`}
>
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
</button>
<button
onClick={handleRepost}
disabled={reposting || reposted}
className={`text-[11px] transition-colors disabled:cursor-default ${
reposted ? "text-accent" : "text-text-dim hover:text-accent"
}`}
>
{reposted ? "reposted ✓" : reposting ? "…" : "repost"}
</button>
<button
onClick={() => setShowQuote(true)}
className="text-[11px] text-text-dim hover:text-text transition-colors"
>
quote
</button>
{hasLightning && (
<button
onClick={() => setShowZap(true)}
className="text-[11px] text-text-dim hover:text-zap transition-colors"
>
{zapData && zapData.totalSats > 0
? `${zapData.totalSats.toLocaleString()} sats`
: "⚡ zap"}
</button>
)}
</div>
)}
{showZap && (
<ZapModal
target={{ type: "note", event, recipientPubkey: event.pubkey }}
recipientName={name}
onClose={() => setShowZap(false)}
/>
)}
{showQuote && (
<QuoteModal
event={event}
authorName={name}
authorAvatar={avatar}
onClose={() => setShowQuote(false)}
/>
)}
<div className="flex items-center gap-2 mb-1.5 text-[11px]">
<span className="text-text-dim">replying to</span>
<span className="text-accent font-medium">@{name}</span>
<button onClick={onClear} className="text-text-dim hover:text-text transition-colors">x</button>
</div>
);
}
@@ -149,34 +30,119 @@ export function ThreadView() {
const { loggedIn } = useUserStore();
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
if (!selectedNote) { goBack(); return null; }
const event = selectedNote;
const focusedEvent = selectedNote;
const [replies, setReplies] = useState<NDKEvent[]>([]);
const [rootEvent, setRootEvent] = useState<NDKEvent | null>(null);
const [ancestors, setAncestors] = useState<NDKEvent[]>([]);
const [tree, setTree] = useState<ThreadNode | null>(null);
const [loading, setLoading] = useState(true);
const [replyTarget, setReplyTarget] = useState<NDKEvent | null>(null);
const [replyText, setReplyText] = useState("");
const [replying, setReplying] = useState(false);
const [replySent, setReplySent] = useState(false);
const [showReplyEmoji, setShowReplyEmoji] = useState(false);
const replyRef = useRef<HTMLTextAreaElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
fetchReplies(event.id).then((r) => {
setReplies(r);
setLoading(false);
}).catch(() => setLoading(false));
}, [event.id]);
let cancelled = false;
async function loadThread() {
setLoading(true);
setTree(null);
setAncestors([]);
setRootEvent(null);
setReplyTarget(null);
try {
// Determine root
const rootId = getRootEventId(focusedEvent);
let root: NDKEvent;
if (!rootId || rootId === focusedEvent.id) {
// This IS the root
root = focusedEvent;
} else {
// Fetch the root event
const fetched = await fetchNoteById(rootId);
if (fetched) {
root = fetched;
// Fetch ancestors between root and focused
const anc = await fetchAncestors(focusedEvent);
if (!cancelled) setAncestors(anc.filter((a) => a.id !== root.id));
} else {
// Root not found, treat focused as root
root = focusedEvent;
}
}
if (cancelled) return;
setRootEvent(root);
// Fetch all thread events and build tree
const events = await fetchThreadEvents(root.id);
if (cancelled) return;
// Include root in the event set
const allEvents = [root, ...events.filter((e) => e.id !== root.id)];
const built = buildThreadTree(root.id, allEvents);
setTree(built);
} catch (err) {
console.error("Failed to load thread:", err);
} finally {
if (!cancelled) setLoading(false);
}
}
loadThread();
return () => { cancelled = true; };
}, [focusedEvent.id]);
// Scroll to focused note after tree renders (if not root)
useEffect(() => {
if (!loading && rootEvent && focusedEvent.id !== rootEvent.id) {
// Small delay to allow DOM to render
const timer = setTimeout(() => {
const el = document.querySelector(`[data-note-id="${focusedEvent.id}"]`);
el?.scrollIntoView({ behavior: "smooth", block: "center" });
}, 100);
return () => clearTimeout(timer);
}
}, [loading, rootEvent?.id, focusedEvent.id]);
const handleReplyInThread = (event: NDKEvent) => {
setReplyTarget(event);
setTimeout(() => replyRef.current?.focus(), 50);
};
const effectiveReplyTarget = replyTarget ?? rootEvent;
const handleReply = async () => {
if (!replyText.trim() || replying) return;
if (!replyText.trim() || replying || !rootEvent) return;
setReplying(true);
try {
const replyEvent = await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
const target = effectiveReplyTarget ?? rootEvent;
const rootArg = target.id !== rootEvent.id
? { id: rootEvent.id, pubkey: rootEvent.pubkey }
: undefined;
const replyEvent = await publishReply(
replyText.trim(),
{ id: target.id, pubkey: target.pubkey },
rootArg,
);
setReplyText("");
setReplySent(true);
// Inject reply locally so it appears immediately
setReplies((prev) => [...prev, replyEvent]);
// Also try fetching from relay in background
fetchReplies(event.id).then((updated) => setReplies(updated));
setReplyTarget(null);
// Optimistically insert into tree
if (tree) {
const allEvents = collectEvents(tree);
allEvents.push(replyEvent);
const rebuilt = buildThreadTree(rootEvent.id, allEvents);
setTree(rebuilt);
}
setTimeout(() => setReplySent(false), 2000);
} finally {
setReplying(false);
@@ -201,79 +167,123 @@ export function ThreadView() {
<h1 className="text-text text-sm font-medium">Thread</h1>
</header>
<div className="flex-1 overflow-y-auto">
{/* Root note */}
<RootNote event={event} />
{/* Reply composer */}
{loggedIn && (
<div className="border-b border-border px-4 py-3">
<textarea
ref={replyRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write a reply…"
rows={2}
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
/>
<div className="flex items-center justify-end gap-2 mt-1">
<div className="relative">
<button
onClick={() => setShowReplyEmoji((v) => !v)}
title="Insert emoji"
className="text-text-dim hover:text-text text-[12px] transition-colors"
>
</button>
{showReplyEmoji && (
<EmojiPicker
onSelect={(emoji) => {
const ta = replyRef.current;
if (ta) {
const start = ta.selectionStart ?? replyText.length;
const end = ta.selectionEnd ?? replyText.length;
setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end));
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
} else {
setReplyText((t) => t + emoji);
}
}}
onClose={() => setShowReplyEmoji(false)}
/>
)}
</div>
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
<button
onClick={handleReply}
disabled={!replyText.trim() || replying}
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
</button>
<div ref={scrollRef} className="flex-1 overflow-y-auto">
{/* Loading shimmer */}
{loading && (
<div className="px-4 py-6 space-y-4">
<div className="animate-pulse space-y-3">
<div className="h-10 bg-bg-raised rounded" />
<div className="h-24 bg-bg-raised rounded" />
<div className="h-16 bg-bg-raised rounded ml-4" />
<div className="h-16 bg-bg-raised rounded ml-4" />
</div>
</div>
)}
{/* Replies */}
{loading && (
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
Loading replies
</div>
{!loading && tree && rootEvent && (
<>
{/* Ancestors (when opening a deep reply) */}
<AncestorChain ancestors={ancestors} />
{/* Root note rendered via tree */}
<div data-note-id={tree.event.id}>
<NoteCard
event={tree.event}
focused={tree.event.id === focusedEvent.id}
onReplyInThread={handleReplyInThread}
/>
</div>
{/* Reply composer */}
{loggedIn && !!getNDK().signer && (
<div className="border-b border-border px-4 py-3">
{replyTarget && replyTarget.id !== rootEvent.id && (
<ReplyTargetBadge event={replyTarget} onClear={() => setReplyTarget(null)} />
)}
<textarea
ref={replyRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Write a reply..."
rows={2}
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
/>
<div className="flex items-center justify-end gap-2 mt-1">
<div className="relative">
<button
onClick={() => setShowReplyEmoji((v) => !v)}
title="Insert emoji"
className="text-text-dim hover:text-text text-[12px] transition-colors"
>
</button>
{showReplyEmoji && (
<EmojiPicker
onSelect={(emoji) => {
const ta = replyRef.current;
if (ta) {
const start = ta.selectionStart ?? replyText.length;
const end = ta.selectionEnd ?? replyText.length;
setReplyText(replyText.slice(0, start) + emoji + replyText.slice(end));
setTimeout(() => { ta.selectionStart = ta.selectionEnd = start + emoji.length; ta.focus(); }, 0);
} else {
setReplyText((t) => t + emoji);
}
}}
onClose={() => setShowReplyEmoji(false)}
/>
)}
</div>
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
<button
onClick={handleReply}
disabled={!replyText.trim() || replying}
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
>
{replySent ? "replied ✓" : replying ? "posting..." : "reply"}
</button>
</div>
</div>
)}
{/* Thread tree (children of root) */}
{tree.children.length === 0 && (
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
No replies yet.
</div>
)}
{tree.children
.filter((c) => !mutedPubkeys.includes(c.event.pubkey) && !contentMatchesMutedKeyword(c.event.content))
.map((child) => (
<ThreadNodeComponent
key={child.event.id}
node={child}
onReplyInThread={handleReplyInThread}
focusedId={focusedEvent.id}
mutedPubkeys={mutedPubkeys}
contentMatchesMutedKeyword={contentMatchesMutedKeyword}
/>
))}
</>
)}
{!loading && replies.length === 0 && (
{!loading && !tree && (
<div className="px-4 py-6 text-text-dim text-[12px] text-center">
No replies yet.
Could not load thread.
</div>
)}
{replies
.filter((r) => !mutedPubkeys.includes(r.pubkey) && !contentMatchesMutedKeyword(r.content))
.map((reply) => (
<NoteCard key={reply.id} event={reply} />
))}
</div>
</div>
);
}
/** Collect all events from a tree into a flat array. */
function collectEvents(node: ThreadNode): NDKEvent[] {
const result: NDKEvent[] = [node.event];
for (const child of node.children) {
result.push(...collectEvents(child));
}
return result;
}