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;
}

View File

@@ -1,5 +1,5 @@
export { getNDK, connectToRelays, getStoredRelayUrls, addRelay, removeRelay } from "./core";
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed } from "./notes";
export { fetchGlobalFeed, fetchFollowFeed, fetchUserNotes, fetchUserNotesNIP65, fetchNoteById, fetchReplies, publishNote, publishReply, publishRepost, publishQuote, fetchHashtagFeed, fetchThreadEvents, fetchAncestors } from "./notes";
export { publishProfile, publishContactList, fetchProfile, fetchFollowSuggestions, fetchMentions, fetchNewFollowers } from "./social";
export { publishArticle, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchByAddr } from "./articles";
export { publishReaction, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchBatchEngagement, fetchZapsReceived, fetchZapsSent } from "./engagement";

View File

@@ -94,17 +94,33 @@ export async function publishNote(content: string): Promise<NDKEvent> {
return event;
}
export async function publishReply(content: string, replyTo: { id: string; pubkey: string }): Promise<NDKEvent> {
export async function publishReply(
content: string,
replyTo: { id: string; pubkey: string },
rootEvent?: { id: string; pubkey: string },
): Promise<NDKEvent> {
const instance = getNDK();
if (!instance.signer) throw new Error("Not logged in");
const event = new NDKEvent(instance);
event.kind = NDKKind.Text;
event.content = content;
event.tags = [
["e", replyTo.id, "", "reply"],
["p", replyTo.pubkey],
];
if (rootEvent && rootEvent.id !== replyTo.id) {
// Replying to a reply — emit both root and reply markers (NIP-10)
const pTags = new Set([rootEvent.pubkey, replyTo.pubkey]);
event.tags = [
["e", rootEvent.id, "", "root"],
["e", replyTo.id, "", "reply"],
...Array.from(pTags).map((p) => ["p", p]),
];
} else {
// Replying directly to root
event.tags = [
["e", replyTo.id, "", "root"],
["p", replyTo.pubkey],
];
}
await event.publish();
return event;
}
@@ -140,6 +156,55 @@ export async function publishQuote(content: string, quotedEvent: NDKEvent): Prom
await note.publish();
}
export async function fetchThreadEvents(rootId: string): Promise<NDKEvent[]> {
const instance = getNDK();
// Round-trip 1: all events tagging the root
const directFilter: NDKFilter = { kinds: [NDKKind.Text], "#e": [rootId] };
const directEvents = await instance.fetchEvents(directFilter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
const allEvents = new Map<string, NDKEvent>();
for (const e of directEvents) allEvents.set(e.id, e);
// Round-trip 2: replies to any event in the thread (catches deep replies that only tag parent)
const knownIds = Array.from(allEvents.keys());
if (knownIds.length > 0) {
const deepFilter: NDKFilter = { kinds: [NDKKind.Text], "#e": knownIds };
const deepEvents = await instance.fetchEvents(deepFilter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
for (const e of deepEvents) allEvents.set(e.id, e);
}
return Array.from(allEvents.values());
}
export async function fetchAncestors(event: NDKEvent, maxDepth = 5): Promise<NDKEvent[]> {
const ancestors: NDKEvent[] = [];
let current = event;
for (let i = 0; i < maxDepth; i++) {
const eTags = current.tags.filter((t) => t[0] === "e");
if (eTags.length === 0) break;
// Walk up: prefer "reply" marker, then "root", then last e-tag
const parentId =
eTags.find((t) => t[3] === "reply")?.[1] ??
eTags.find((t) => t[3] === "root")?.[1] ??
eTags[eTags.length - 1][1];
if (!parentId) break;
const parent = await fetchNoteById(parentId);
if (!parent) break;
ancestors.unshift(parent); // root-first order
current = parent;
}
return ancestors;
}
export async function fetchHashtagFeed(tag: string, limit = 100): Promise<NDKEvent[]> {
const instance = getNDK();
const filter: NDKFilter = {

104
src/lib/threadTree.ts Normal file
View File

@@ -0,0 +1,104 @@
import { NDKEvent } from "@nostr-dev-kit/ndk";
export interface ThreadNode {
event: NDKEvent;
children: ThreadNode[];
depth: number;
}
/**
* Extract the parent event ID from an event's e-tags.
* Priority: "reply" marker > "root" marker > last e-tag (deprecated positional).
*/
export 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];
}
/**
* Extract the root event ID from an event's e-tags.
*/
export function getRootEventId(event: NDKEvent): string | null {
const eTags = event.tags.filter((t) => t[0] === "e");
if (eTags.length === 0) return null;
const root = eTags.find((t) => t[3] === "root");
if (root) return root[1];
// If only one e-tag with no marker, it's the root
if (eTags.length === 1 && !eTags[0][3]) return eTags[0][1];
// Deprecated positional: first e-tag is root
return eTags[0][1];
}
/**
* Build a tree structure from a flat list of events.
* Returns the root node, or null if rootId not found in events.
*/
export function buildThreadTree(rootId: string, events: NDKEvent[]): ThreadNode | null {
const eventMap = new Map<string, NDKEvent>();
for (const e of events) {
eventMap.set(e.id, e);
}
const rootEvent = eventMap.get(rootId);
if (!rootEvent) return null;
const nodeMap = new Map<string, ThreadNode>();
for (const e of events) {
nodeMap.set(e.id, { event: e, children: [], depth: 0 });
}
const rootNode = nodeMap.get(rootId)!;
// Link children to parents
for (const e of events) {
if (e.id === rootId) continue;
const parentId = getParentId(e, rootId);
const parentNode = parentId ? nodeMap.get(parentId) : null;
const childNode = nodeMap.get(e.id)!;
if (parentNode) {
parentNode.children.push(childNode);
} else {
// Orphan — attach to root
rootNode.children.push(childNode);
}
}
// Set depths and sort children by created_at
setDepths(rootNode, 0);
return rootNode;
}
function getParentId(event: NDKEvent, rootId: string): string | null {
const eTags = event.tags.filter((t) => t[0] === "e");
if (eTags.length === 0) return rootId;
// Prefer "reply" marker
const reply = eTags.find((t) => t[3] === "reply");
if (reply) return reply[1];
// If there's a "root" marker and it's the only e-tag, parent is the root
const root = eTags.find((t) => t[3] === "root");
if (root && eTags.length === 1) return root[1];
// If there's a root marker and other e-tags without markers, use the last non-root e-tag
if (root) {
const nonRoot = eTags.filter((t) => t[3] !== "root");
if (nonRoot.length > 0) return nonRoot[nonRoot.length - 1][1];
return root[1];
}
// Deprecated positional: last e-tag is the reply target
return eTags[eTags.length - 1][1];
}
function setDepths(node: ThreadNode, depth: number) {
node.depth = depth;
node.children.sort((a, b) => (a.event.created_at ?? 0) - (b.event.created_at ?? 0));
for (const child of node.children) {
setDepths(child, depth + 1);
}
}

View File

@@ -9,6 +9,7 @@ describe("useUIStore", () => {
selectedPubkey: null,
selectedNote: null,
previousView: "feed",
viewStack: [],
feedTab: "global",
pendingSearch: null,
pendingDMPubkey: null,

View File

@@ -5,12 +5,21 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
type View = "feed" | "search" | "relays" | "settings" | "profile" | "thread" | "article-editor" | "article" | "articles" | "media" | "podcasts" | "about" | "zaps" | "dm" | "notifications" | "bookmarks" | "hashtag";
type FeedTab = "global" | "following" | "trending";
interface ViewStackEntry {
view: View;
selectedNote: NDKEvent | null;
selectedPubkey: string | null;
}
const MAX_STACK = 20;
interface UIState {
currentView: View;
sidebarCollapsed: boolean;
selectedPubkey: string | null;
selectedNote: NDKEvent | null;
previousView: View;
viewStack: ViewStackEntry[];
feedTab: FeedTab;
pendingSearch: string | null;
pendingDMPubkey: string | null;
@@ -22,7 +31,7 @@ interface UIState {
setView: (view: View) => void;
setFeedTab: (tab: FeedTab) => void;
openProfile: (pubkey: string) => void;
openThread: (note: NDKEvent, from: View) => void;
openThread: (note: NDKEvent, from?: View) => void;
openSearch: (query: string) => void;
openHashtag: (tag: string) => void;
openDM: (pubkey: string) => void;
@@ -41,6 +50,7 @@ export const useUIStore = create<UIState>((set, _get) => ({
selectedPubkey: null,
selectedNote: null,
previousView: "feed",
viewStack: [],
feedTab: "global",
pendingSearch: null,
pendingDMPubkey: null,
@@ -51,17 +61,32 @@ export const useUIStore = create<UIState>((set, _get) => ({
feedLanguageFilter: null,
setView: (currentView) => set({ currentView }),
setFeedTab: (feedTab) => set({ feedTab }),
openProfile: (pubkey) => set((s) => ({ currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View })),
openThread: (note, from) => set({ currentView: "thread", selectedNote: note, previousView: from }),
openProfile: (pubkey) => set((s) => {
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
return { currentView: "profile", selectedPubkey: pubkey, previousView: s.currentView as View, viewStack: stack };
}),
openThread: (note, _from) => set((s) => {
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
return { currentView: "thread", selectedNote: note, previousView: s.currentView as View, viewStack: stack };
}),
openSearch: (query) => set({ currentView: "search", pendingSearch: query }),
openHashtag: (tag) => set((s) => ({ currentView: "hashtag", pendingHashtag: tag, previousView: s.currentView as View })),
openHashtag: (tag) => set((s) => {
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
return { currentView: "hashtag", pendingHashtag: tag, previousView: s.currentView as View, viewStack: stack };
}),
openDM: (pubkey) => set({ currentView: "dm", pendingDMPubkey: pubkey }),
openArticle: (naddr, event) => set((s) => ({ currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View })),
goBack: () => set((s) => ({
showHelp: false,
currentView: s.previousView !== s.currentView ? s.previousView : "feed",
selectedNote: null,
})),
openArticle: (naddr, event) => set((s) => {
const stack = [...s.viewStack, { view: s.currentView, selectedNote: s.selectedNote, selectedPubkey: s.selectedPubkey }].slice(-MAX_STACK);
return { currentView: "article", pendingArticleNaddr: naddr, pendingArticleEvent: event ?? null, previousView: s.currentView as View, viewStack: stack };
}),
goBack: () => set((s) => {
const stack = [...s.viewStack];
const prev = stack.pop();
if (prev) {
return { showHelp: false, currentView: prev.view, selectedNote: prev.selectedNote, selectedPubkey: prev.selectedPubkey, viewStack: stack };
}
return { showHelp: false, currentView: "feed", selectedNote: null, viewStack: [] };
}),
setFeedLanguageFilter: (feedLanguageFilter) => set({ feedLanguageFilter }),
toggleSidebar: () => set((s) => {
const next = !s.sidebarCollapsed;