mirror of
https://github.com/hoornet/vega.git
synced 2026-05-07 12:49:13 -07:00
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:
@@ -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";
|
||||
|
||||
@@ -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
104
src/lib/threadTree.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user