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}
/>
)}