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

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