import { Lazy } from "./lazy.ts"; import type { NDKEvent } from "@nostr-dev-kit/ndk"; import type NDK from "@nostr-dev-kit/ndk"; import { fetchEventById } from "../utils/websocket_utils.ts"; import { fetchEventWithFallback, NDKRelaySetFromNDK, } from "../utils/nostrUtils.ts"; import { get } from "svelte/store"; import { activeInboxRelays, activeOutboxRelays } from "../ndk.ts"; import { searchRelays, secondaryRelays } from "../consts.ts"; enum PublicationTreeNodeType { Branch, Leaf, } enum PublicationTreeNodeStatus { Resolved, Error, } export enum TreeTraversalMode { Leaves, All, } enum TreeTraversalDirection { Forward, Backward, } interface PublicationTreeNode { type: PublicationTreeNodeType; status: PublicationTreeNodeStatus; address: string; parent?: PublicationTreeNode; children?: Array>; } export class PublicationTree implements AsyncIterable { /** * The root node of the tree. */ #root: PublicationTreeNode; /** * A map of addresses in the tree to their corresponding nodes. */ #nodes: Map>; /** * A map of addresses in the tree to their corresponding events. */ #events: Map; /** * Simple cache for fetched events to avoid re-fetching. */ #eventCache: Map = new Map(); /** * An ordered list of the addresses of the leaves of the tree. */ #leaves: string[] = []; /** * The address of the last-visited node. Used for iteration and progressive retrieval. */ #bookmark?: string; /** * AI-NOTE: Track visited nodes to prevent duplicate iteration * This ensures that each node is only yielded once during iteration */ #visitedNodes: Set = new Set(); /** * The NDK instance used to fetch events. */ #ndk: NDK; #nodeAddedObservers: Array<(address: string) => void> = []; #nodeResolvedObservers: Array<(address: string) => void> = []; #bookmarkMovedObservers: Array<(address: string) => void> = []; constructor(rootEvent: NDKEvent, ndk: NDK) { const rootAddress = rootEvent.tagAddress(); this.#root = { type: PublicationTreeNodeType.Branch, status: PublicationTreeNodeStatus.Resolved, address: rootAddress, children: [], }; this.#nodes = new Map>(); this.#nodes.set( rootAddress, new Lazy(() => Promise.resolve(this.#root)), ); this.#events = new Map(); this.#events.set(rootAddress, rootEvent); this.#ndk = ndk; } /** * Adds an event to the publication tree. * @param event The event to be added. * @param parentEvent The parent event of the event to be added. * @throws An error if the parent event is not in the tree. * @description The parent event must already be in the tree. Use * {@link PublicationTree.getEvent} to retrieve an event already in the tree. */ async addEvent(event: NDKEvent, parentEvent: NDKEvent) { const address = event.tagAddress(); const parentAddress = parentEvent.tagAddress(); const parentNode = await this.#nodes.get(parentAddress)?.value(); if (!parentNode) { throw new Error( `PublicationTree: Parent node with address ${parentAddress} not found.`, ); } const node: PublicationTreeNode = { type: await this.#getNodeType(event), status: PublicationTreeNodeStatus.Resolved, address, parent: parentNode, children: [], }; const lazyNode = new Lazy(() => Promise.resolve(node)); parentNode.children!.push(lazyNode); this.#nodes.set(address, lazyNode); this.#events.set(address, event); } /** * Lazily adds an event to the publication tree by address if the full event is not already * loaded into memory. * @param address The address of the event to add. * @param parentEvent The parent event of the event to add. * @description The parent event must already be in the tree. Use * {@link PublicationTree.getEvent} to retrieve an event already in the tree. */ async addEventByAddress(address: string, parentEvent: NDKEvent) { const parentAddress = parentEvent.tagAddress(); const parentNode = await this.#nodes.get(parentAddress)?.value(); if (!parentNode) { throw new Error( `PublicationTree: Parent node with address ${parentAddress} not found.`, ); } this.#addNode(address, parentNode); } /** * Retrieves an event from the publication tree. * @param address The address of the event to retrieve. * @returns The event, or null if the event is not found. */ async getEvent(address: string): Promise { let event = this.#events.get(address) ?? null; if (!event) { event = await this.#depthFirstRetrieve(address); } return event; } /** * Retrieves the addresses of the loaded children, if any, of the node with the given address. * * @param address The address of the parent node. * @returns An array of addresses of any loaded child nodes. * * Note that this method resolves all children of the node. */ async getChildAddresses(address: string): Promise> { const node = await this.#nodes.get(address)?.value(); if (!node) { throw new Error( `[PublicationTree] Node with address ${address} not found.`, ); } return Promise.all( node.children?.map( async (child) => (await child.value())?.address ?? null, ) ?? [], ); } /** * Retrieves the events in the hierarchy of the event with the given address. * @param address The address of the event for which to retrieve the hierarchy. * @returns Returns an array of events in the addressed event's hierarchy, beginning with the * root and ending with the addressed event. */ async getHierarchy(address: string): Promise { let node = await this.#nodes.get(address)?.value(); if (!node) { throw new Error( `[PublicationTree] Node with address ${address} not found.`, ); } const hierarchy: NDKEvent[] = [this.#events.get(address)!]; while (node.parent) { hierarchy.push(this.#events.get(node.parent.address)!); node = node.parent; } return hierarchy.reverse(); } /** * Sets a start point for iteration over the leaves of the tree. * @param address The address of the event to bookmark. */ setBookmark(address: string) { this.#bookmark = address; this.#cursor.tryMoveTo(address).then((success) => { if (success) { this.#bookmarkMovedObservers.forEach((observer) => observer(address)); } }); } /** * AI-NOTE: Reset the cursor to the beginning of the tree * This is useful when the component state is reset and we want to start iteration from the beginning */ resetCursor() { this.#bookmark = undefined; this.#cursor.target = null; } /** * AI-NOTE: Reset the iterator state to start from the beginning * This ensures that when the component resets, the iterator starts fresh */ resetIterator() { this.resetCursor(); // Clear visited nodes to allow fresh iteration this.#visitedNodes.clear(); // Clear all nodes except the root to force fresh loading const rootAddress = this.#root.address; this.#nodes.clear(); this.#nodes.set(rootAddress, new Lazy(() => Promise.resolve(this.#root))); // Clear events cache to ensure fresh data this.#events.clear(); this.#eventCache.clear(); // Force the cursor to move to the root node to restart iteration this.#cursor.tryMoveTo().then((success) => { if (!success) { console.warn("[PublicationTree] Failed to reset iterator to root node"); } }); } onBookmarkMoved(observer: (address: string) => void) { this.#bookmarkMovedObservers.push(observer); } onNodeAdded(observer: (address: string) => void) { this.#nodeAddedObservers.push(observer); } /** * Registers an observer function that is invoked whenever a new node is resolved. Nodes are * added lazily. * * @param observer The observer function. */ onNodeResolved(observer: (address: string) => void) { this.#nodeResolvedObservers.push(observer); } // #region Iteration Cursor #cursor = new (class { target: PublicationTreeNode | null | undefined; #tree: PublicationTree; constructor(tree: PublicationTree) { this.#tree = tree; } async tryMoveTo(address?: string) { if (!address) { const startEvent = await this.#tree.#depthFirstRetrieve(); if (!startEvent) { return false; } this.target = await this.#tree.#nodes .get(startEvent.tagAddress()) ?.value(); } else { this.target = await this.#tree.#nodes.get(address)?.value(); } if (!this.target) { return false; } return true; } async tryMoveToFirstChild(): Promise { if (!this.target) { console.debug( "[Publication Tree Cursor] Target node is null or undefined.", ); return false; } if (this.target.type === PublicationTreeNodeType.Leaf) { return false; } if (this.target.children == null || this.target.children.length === 0) { return false; } this.target = await this.target.children?.at(0)?.value(); return true; } async tryMoveToLastChild(): Promise { if (!this.target) { console.debug( "[Publication Tree Cursor] Target node is null or undefined.", ); return false; } if (this.target.type === PublicationTreeNodeType.Leaf) { return false; } if (this.target.children == null || this.target.children.length === 0) { return false; } this.target = await this.target.children?.at(-1)?.value(); return true; } async tryMoveToNextSibling(): Promise { if (!this.target) { console.debug( "[Publication Tree Cursor] Target node is null or undefined.", ); return false; } const parent = this.target.parent; const siblings = parent?.children; if (!siblings) { return false; } const currentIndex = await siblings.findIndexAsync( async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address, ); if (currentIndex === -1) { return false; } if (currentIndex + 1 >= siblings.length) { return false; } this.target = await siblings.at(currentIndex + 1)?.value(); return true; } async tryMoveToPreviousSibling(): Promise { if (!this.target) { console.debug( "[Publication Tree Cursor] Target node is null or undefined.", ); return false; } const parent = this.target.parent; const siblings = parent?.children; if (!siblings) { return false; } const currentIndex = await siblings.findIndexAsync( async (sibling: Lazy) => (await sibling.value())?.address === this.target!.address, ); if (currentIndex === -1) { return false; } if (currentIndex <= 0) { return false; } this.target = await siblings.at(currentIndex - 1)?.value(); return true; } tryMoveToParent(): boolean { if (!this.target) { console.debug( "[Publication Tree Cursor] Target node is null or undefined.", ); return false; } const parent = this.target.parent; if (!parent) { return false; } this.target = parent; return true; } })(this); // #endregion // #region Async Iterator Implementation [Symbol.asyncIterator](): AsyncIterator { return this; } /** * Return the next event in the tree for the given traversal mode. * * @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or * {@link TreeTraversalMode.All}. * @returns The next event in the tree, or null if the tree is empty. */ async next( mode: TreeTraversalMode = TreeTraversalMode.Leaves, ): Promise> { if (!this.#cursor.target) { if (await this.#cursor.tryMoveTo(this.#bookmark)) { return this.#yieldEventAtCursor(false); } } switch (mode) { case TreeTraversalMode.Leaves: return this.#walkLeaves(TreeTraversalDirection.Forward); case TreeTraversalMode.All: return this.#preorderWalkAll(TreeTraversalDirection.Forward); } } /** * Return the previous event in the tree for the given traversal mode. * * @param mode The traversal mode. Can be {@link TreeTraversalMode.Leaves} or * {@link TreeTraversalMode.All}. * @returns The previous event in the tree, or null if the tree is empty. */ async previous( mode: TreeTraversalMode = TreeTraversalMode.Leaves, ): Promise> { if (!this.#cursor.target) { if (await this.#cursor.tryMoveTo(this.#bookmark)) { return this.#yieldEventAtCursor(false); } } switch (mode) { case TreeTraversalMode.Leaves: return this.#walkLeaves(TreeTraversalDirection.Backward); case TreeTraversalMode.All: return this.#preorderWalkAll(TreeTraversalDirection.Backward); } } async #yieldEventAtCursor( done: boolean, ): Promise> { if (!this.#cursor.target) { return { done, value: null }; } const address = this.#cursor.target.address; // AI-NOTE: Check if this node has already been visited if (this.#visitedNodes.has(address)) { console.debug(`[PublicationTree] Skipping already visited node: ${address}`); return { done: false, value: null }; } // Mark this node as visited this.#visitedNodes.add(address); const value = (await this.getEvent(address)) ?? null; return { done, value }; } /** * Walks the tree in the given direction, yielding the event at each leaf. * * @param direction The direction to walk the tree. * @returns The event at the leaf, or null if the tree is empty. * * Based on Raymond Chen's tree traversal algorithm example. * https://devblogs.microsoft.com/oldnewthing/20200106-00/?p=103300 */ async #walkLeaves( direction: TreeTraversalDirection = TreeTraversalDirection.Forward, ): Promise> { const tryMoveToSibling: () => Promise = direction === TreeTraversalDirection.Forward ? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) : this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor); const tryMoveToChild: () => Promise = direction === TreeTraversalDirection.Forward ? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) : this.#cursor.tryMoveToLastChild.bind(this.#cursor); do { if (await tryMoveToSibling()) { while (await tryMoveToChild()) { continue; } if ( this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error ) { return { done: false, value: null }; } return this.#yieldEventAtCursor(false); } } while (this.#cursor.tryMoveToParent()); if ( this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error ) { return { done: false, value: null }; } // If we get to this point, we're at the root node (can't move up any more). return { done: true, value: null }; } /** * Walks the tree in the given direction, yielding the event at each node. * * @param direction The direction to walk the tree. * @returns The event at the node, or null if the tree is empty. * * Based on Raymond Chen's preorder walk algorithm example. * https://devblogs.microsoft.com/oldnewthing/20200107-00/?p=103304 */ async #preorderWalkAll( direction: TreeTraversalDirection = TreeTraversalDirection.Forward, ): Promise> { const tryMoveToSibling: () => Promise = direction === TreeTraversalDirection.Forward ? this.#cursor.tryMoveToNextSibling.bind(this.#cursor) : this.#cursor.tryMoveToPreviousSibling.bind(this.#cursor); const tryMoveToChild: () => Promise = direction === TreeTraversalDirection.Forward ? this.#cursor.tryMoveToFirstChild.bind(this.#cursor) : this.#cursor.tryMoveToLastChild.bind(this.#cursor); if (await tryMoveToChild()) { return this.#yieldEventAtCursor(false); } do { if (await tryMoveToSibling()) { return this.#yieldEventAtCursor(false); } } while (this.#cursor.tryMoveToParent()); if ( this.#cursor.target && this.#cursor.target.status === PublicationTreeNodeStatus.Error ) { return { done: false, value: null }; } // If we get to this point, we're at the root node (can't move up any more). return this.#yieldEventAtCursor(true); } // #endregion // #region Private Methods /** * Traverses the publication tree in a depth-first manner to retrieve an event, filling in * missing nodes during the traversal. * @param address The address of the event to retrieve. If no address is provided, the function * will return the first leaf in the tree. * @returns The event, or null if the event is not found. */ async #depthFirstRetrieve(address?: string): Promise { if (address && this.#nodes.has(address)) { return this.#events.get(address)!; } const stack: string[] = [this.#root.address]; let currentNode: PublicationTreeNode | null | undefined = this.#root; let currentEvent: NDKEvent | null | undefined = this.#events.get( this.#root.address, )!; while (stack.length > 0) { const currentAddress = stack.pop(); currentNode = await this.#nodes.get(currentAddress!)?.value(); if (!currentNode) { throw new Error( `[PublicationTree] Node with address ${currentAddress} not found.`, ); } currentEvent = this.#events.get(currentAddress!); if (!currentEvent) { console.warn( `[PublicationTree] Event with address ${currentAddress} not found.`, ); return null; } // Stop immediately if the target of the search is found. if (address != null && currentAddress === address) { return currentEvent; } const currentChildAddresses = currentEvent.tags .filter((tag) => tag[0] === "a") .map((tag) => tag[1]); console.debug( `[PublicationTree] Current event ${currentEvent.id} has ${currentEvent.tags.length} tags:`, currentEvent.tags, ); console.debug( `[PublicationTree] Found ${currentChildAddresses.length} a-tags in current event:`, currentChildAddresses, ); // If no a-tags found, try e-tags as fallback if (currentChildAddresses.length === 0) { const eTags = currentEvent.tags .filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]) ); console.debug( `[PublicationTree] Found ${eTags.length} e-tags for current event ${currentEvent.id}:`, eTags.map((tag) => tag[1]), ); // For e-tags with hex IDs, fetch the referenced events to get their addresses const eTagPromises = eTags.map(async (tag) => { try { console.debug( `[PublicationTree] Fetching event for e-tag ${ tag[1] } in depthFirstRetrieve`, ); const referencedEvent = await fetchEventById(tag[1]); if (referencedEvent) { // Construct the proper address format from the referenced event const dTag = referencedEvent.tags.find((tag) => tag[0] === "d") ?.[1]; if (dTag) { const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; console.debug( `[PublicationTree] Constructed address from e-tag in depthFirstRetrieve: ${address}`, ); return address; } else { console.debug( `[PublicationTree] Referenced event ${ tag[1] } has no d-tag in depthFirstRetrieve`, ); } } else { console.debug( `[PublicationTree] Failed to fetch event for e-tag ${ tag[1] } in depthFirstRetrieve - event not found`, ); } return null; } catch (error) { console.warn( `[PublicationTree] Failed to fetch event for e-tag ${ tag[1] } in depthFirstRetrieve:`, error, ); return null; } }); const resolvedAddresses = await Promise.all(eTagPromises); const validAddresses = resolvedAddresses.filter((addr) => addr !== null ) as string[]; console.debug( `[PublicationTree] Resolved ${validAddresses.length} valid addresses from e-tags in depthFirstRetrieve:`, validAddresses, ); if (validAddresses.length > 0) { currentChildAddresses.push(...validAddresses); } } // If the current event has no children, it is a leaf. if (currentChildAddresses.length === 0) { // Return the first leaf if no address was provided. if (address == null) { return currentEvent!; } continue; } // Augment the tree with the children of the current event. const childPromises = currentChildAddresses .filter((childAddress) => !this.#nodes.has(childAddress)) .map((childAddress) => this.#addNode(childAddress, currentNode!)); await Promise.all(childPromises); // Push the popped address's children onto the stack for the next iteration. while (currentChildAddresses.length > 0) { const nextAddress = currentChildAddresses.pop()!; stack.push(nextAddress); } } return null; } #addNode(address: string, parentNode: PublicationTreeNode) { // AI-NOTE: Add debugging to track node addition console.debug(`[PublicationTree] Adding node ${address} to parent ${parentNode.address}`); const lazyNode = new Lazy(() => this.#resolveNode(address, parentNode) ); parentNode.children!.push(lazyNode); this.#nodes.set(address, lazyNode); this.#nodeAddedObservers.forEach((observer) => observer(address)); } /** * Resolves a node address into an event, and creates new nodes for its children. * * This method is intended for use as a {@link Lazy} resolver. * * @param address The address of the node to resolve. * @param parentNode The parent node of the node to resolve. * @returns The resolved node. */ async #resolveNode( address: string, parentNode: PublicationTreeNode, ): Promise { // Check cache first let event = this.#eventCache.get(address); if (!event) { const [kind, pubkey, dTag] = address.split(":"); // AI-NOTE: Enhanced event fetching with comprehensive fallback // First try to fetch using the enhanced fetchEventWithFallback function // which includes search relay fallback logic return fetchEventWithFallback(this.#ndk, { kinds: [parseInt(kind)], authors: [pubkey], "#d": [dTag], }, 5000) // 5 second timeout for publication events .then((fetchedEvent) => { if (fetchedEvent) { // Cache the event if found this.#eventCache.set(address, fetchedEvent); event = fetchedEvent; } if (!event) { console.warn( `[PublicationTree] Event with address ${address} not found on primary relays, trying search relays.`, ); // If still not found, try a more aggressive search using search relays return this.#trySearchRelayFallback( address, kind, pubkey, dTag, parentNode, ); } return this.#buildNodeFromEvent(event, address, parentNode); }) .catch((error) => { console.warn( `[PublicationTree] Error fetching event for address ${address}:`, error, ); // Try search relay fallback even on error return this.#trySearchRelayFallback( address, kind, pubkey, dTag, parentNode, ); }); } return await this.#buildNodeFromEvent(event, address, parentNode); } /** * AI-NOTE: Aggressive search relay fallback for publication events * This method tries to find events on search relays when they're not found on primary relays */ async #trySearchRelayFallback( address: string, kind: string, pubkey: string, dTag: string, parentNode: PublicationTreeNode, ): Promise { try { console.log( `[PublicationTree] Trying search relay fallback for address: ${address}`, ); // Get current relay configuration const inboxRelays = get(activeInboxRelays); const outboxRelays = get(activeOutboxRelays); // Create a comprehensive relay set including search relays const allRelays = [ ...inboxRelays, ...outboxRelays, ...searchRelays, ...secondaryRelays, ]; const uniqueRelays = [...new Set(allRelays)]; // Remove duplicates console.log( `[PublicationTree] Trying ${uniqueRelays.length} relays for fallback search:`, uniqueRelays, ); // Try each relay individually with a shorter timeout for (const relay of uniqueRelays) { try { const relaySet = NDKRelaySetFromNDK.fromRelayUrls([relay], this.#ndk); const fetchedEvent = await this.#ndk.fetchEvent( { kinds: [parseInt(kind)], authors: [pubkey], "#d": [dTag], }, undefined, relaySet, ).withTimeout(3000); // 3 second timeout per relay if (fetchedEvent) { console.log( `[PublicationTree] Found event ${fetchedEvent.id} on search relay: ${relay}`, ); // Cache the event this.#eventCache.set(address, fetchedEvent); this.#events.set(address, fetchedEvent); return await this.#buildNodeFromEvent(fetchedEvent, address, parentNode); } } catch (error) { console.debug( `[PublicationTree] Failed to fetch from relay ${relay}:`, error, ); continue; // Try next relay } } // If we get here, the event was not found on any relay console.warn( `[PublicationTree] Event with address ${address} not found on any relay after fallback search.`, ); return { type: PublicationTreeNodeType.Leaf, status: PublicationTreeNodeStatus.Error, address, parent: parentNode, children: [], }; } catch (error) { console.error( `[PublicationTree] Error in search relay fallback for ${address}:`, error, ); return { type: PublicationTreeNodeType.Leaf, status: PublicationTreeNodeStatus.Error, address, parent: parentNode, children: [], }; } } /** * AI-NOTE: Helper method to build a node from an event * This extracts the common logic for building nodes from events */ async #buildNodeFromEvent( event: NDKEvent, address: string, parentNode: PublicationTreeNode, ): Promise { this.#events.set(address, event); const childAddresses = event.tags .filter((tag) => tag[0] === "a") .map((tag) => tag[1]); console.debug( `[PublicationTree] Event ${event.id} has ${event.tags.length} tags:`, event.tags, ); console.debug( `[PublicationTree] Found ${childAddresses.length} a-tags:`, childAddresses, ); // If no a-tags found, try e-tags as fallback if (childAddresses.length === 0) { const eTags = event.tags .filter((tag) => tag[0] === "e" && tag[1] && /^[0-9a-fA-F]{64}$/.test(tag[1]) ); console.debug( `[PublicationTree] Found ${eTags.length} e-tags for event ${event.id}:`, eTags.map((tag) => tag[1]), ); // For e-tags with hex IDs, fetch the referenced events to get their addresses const eTagPromises = eTags.map(async (tag) => { try { console.debug(`[PublicationTree] Fetching event for e-tag ${tag[1]}`); const referencedEvent = await fetchEventById(tag[1]); if (referencedEvent) { // Construct the proper address format from the referenced event const dTag = referencedEvent.tags.find((tag) => tag[0] === "d") ?.[1]; if (dTag) { const address = `${referencedEvent.kind}:${referencedEvent.pubkey}:${dTag}`; console.debug( `[PublicationTree] Constructed address from e-tag: ${address}`, ); return address; } else { console.debug( `[PublicationTree] Referenced event ${tag[1]} has no d-tag`, ); } } else { console.debug( `[PublicationTree] Failed to fetch event for e-tag ${tag[1]}`, ); } return null; } catch (error) { console.warn( `[PublicationTree] Failed to fetch event for e-tag ${tag[1]}:`, error, ); return null; } }); // AI-NOTE: Remove e-tag processing from synchronous method // E-tags should be resolved asynchronously in #resolveNode method // Adding raw event IDs here causes duplicate processing console.debug(`[PublicationTree] Found ${eTags.length} e-tags but skipping processing in buildNodeFromEvent`); } const node: PublicationTreeNode = { type: this.#getNodeType(event), status: PublicationTreeNodeStatus.Resolved, address, parent: parentNode, children: [], }; // AI-NOTE: Fixed child node addition in buildNodeFromEvent // Previously called addEventByAddress which expected parent to be in tree // Now directly adds child nodes to current node's children array // Add children in the order they appear in the a-tags to preserve section order // Use sequential processing to ensure order is maintained console.log(`[PublicationTree] Adding ${childAddresses.length} children in order:`, childAddresses); for (const childAddress of childAddresses) { console.log(`[PublicationTree] Adding child: ${childAddress}`); try { // Add the child node directly to the current node's children this.#addNode(childAddress, node); console.log(`[PublicationTree] Successfully added child: ${childAddress}`); } catch (error) { console.warn( `[PublicationTree] Error adding child ${childAddress} for ${node.address}:`, error, ); } } this.#nodeResolvedObservers.forEach((observer) => observer(address)); return node; } #getNodeType(event: NDKEvent): PublicationTreeNodeType { // AI-NOTE: Show nested 30040s and their zettel kind leaves // Only 30040 events with children should be branches // Zettel kinds (30041, 30818, 30023) are always leaves if (event.kind === 30040) { // Check if this 30040 has any children (a-tags only, since e-tags are handled separately) const hasChildren = event.tags.some((tag) => tag[0] === "a"); console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`); return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf; } // Zettel kinds are always leaves if ([30041, 30818, 30023].includes(event.kind)) { console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - Zettel kind, type: Leaf`); return PublicationTreeNodeType.Leaf; } // For other kinds, check if they have children (a-tags only) const hasChildren = event.tags.some((tag) => tag[0] === "a"); console.debug(`[PublicationTree] Node type for ${event.kind}:${event.pubkey}:${event.tags.find(t => t[0] === 'd')?.[1]} - hasChildren: ${hasChildren}, type: ${hasChildren ? 'Branch' : 'Leaf'}`); return hasChildren ? PublicationTreeNodeType.Branch : PublicationTreeNodeType.Leaf; } // #endregion }