From 6651b8394b61ad082cae3473c8e73ea30ba8d033 Mon Sep 17 00:00:00 2001 From: silberengel Date: Sun, 7 Dec 2025 22:47:23 +0100 Subject: [PATCH] expand upward from jump --- .../publications/CommentLayer.svelte | 42 +- .../publications/HighlightLayer.svelte | 42 +- .../publications/Publication.svelte | 399 +++++++++++++++--- 3 files changed, 403 insertions(+), 80 deletions(-) diff --git a/src/lib/components/publications/CommentLayer.svelte b/src/lib/components/publications/CommentLayer.svelte index 0ddda39..849114e 100644 --- a/src/lib/components/publications/CommentLayer.svelte +++ b/src/lib/components/publications/CommentLayer.svelte @@ -132,6 +132,32 @@ const ws = await WebSocketPool.instance.acquire(relayUrl); return new Promise((resolve) => { + let released = false; + let resolved = false; + + const releaseConnection = () => { + if (released) { + return; + } + released = true; + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.send(JSON.stringify(["CLOSE", subscriptionId])); + } + ws.removeEventListener("message", messageHandler); + WebSocketPool.instance.release(ws); + } catch (err) { + console.error(`[CommentLayer] Error releasing connection to ${relayUrl}:`, err); + } + }; + + const safeResolve = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); @@ -163,11 +189,9 @@ eoseCount++; console.log(`[CommentLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`); - // Close subscription - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - resolve(); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } else if (message[0] === "NOTICE") { console.warn(`[CommentLayer] NOTICE from ${relayUrl}:`, message[1]); } @@ -189,12 +213,8 @@ // Timeout per relay (5 seconds) setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - } - resolve(); + releaseConnection(); + safeResolve(); }, 5000); }); } catch (err) { diff --git a/src/lib/components/publications/HighlightLayer.svelte b/src/lib/components/publications/HighlightLayer.svelte index 48b00f6..b9d707e 100644 --- a/src/lib/components/publications/HighlightLayer.svelte +++ b/src/lib/components/publications/HighlightLayer.svelte @@ -190,6 +190,32 @@ const ws = await WebSocketPool.instance.acquire(relayUrl); return new Promise((resolve) => { + let released = false; + let resolved = false; + + const releaseConnection = () => { + if (released) { + return; + } + released = true; + try { + if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) { + ws.send(JSON.stringify(["CLOSE", subscriptionId])); + } + ws.removeEventListener("message", messageHandler); + WebSocketPool.instance.release(ws); + } catch (err) { + console.error(`[HighlightLayer] Error releasing connection to ${relayUrl}:`, err); + } + }; + + const safeResolve = () => { + if (!resolved) { + resolved = true; + resolve(); + } + }; + const messageHandler = (event: MessageEvent) => { try { const message = JSON.parse(event.data); @@ -231,11 +257,9 @@ `[HighlightLayer] EOSE from ${relayUrl} (${eoseCount}/${uniqueRelays.length})`, ); - // Close subscription - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - resolve(); + // Close subscription and release connection + releaseConnection(); + safeResolve(); } else if (message[0] === "NOTICE") { console.warn( `[HighlightLayer] NOTICE from ${relayUrl}:`, @@ -266,12 +290,8 @@ // Timeout per relay (5 seconds) setTimeout(() => { - if (ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify(["CLOSE", subscriptionId])); - ws.removeEventListener("message", messageHandler); - WebSocketPool.instance.release(ws); - } - resolve(); + releaseConnection(); + safeResolve(); }, 5000); }); } catch (err) { diff --git a/src/lib/components/publications/Publication.svelte b/src/lib/components/publications/Publication.svelte index b07bc82..4907384 100644 --- a/src/lib/components/publications/Publication.svelte +++ b/src/lib/components/publications/Publication.svelte @@ -351,14 +351,31 @@ * Loads sections before a given address in the TOC order. */ async function loadSectionsBefore(referenceAddress: string, count: number = AUTO_LOAD_BATCH_SIZE) { - if (!publicationTree || !toc || isLoading) { + console.log("[Publication] loadSectionsBefore called:", { + referenceAddress, + count, + hasPublicationTree: !!publicationTree, + hasToc: !!toc, + isLoading, + isLoadingUpward + }); + + if (!publicationTree || !toc) { + console.log("[Publication] loadSectionsBefore: Early return (missing dependencies)"); return; } const allAddresses = getAllSectionAddresses(); const referenceIndex = allAddresses.indexOf(referenceAddress); + console.log("[Publication] loadSectionsBefore: Reference index:", { + referenceIndex, + totalAddresses: allAddresses.length, + referenceAddress + }); + if (referenceIndex === -1 || referenceIndex === 0) { + console.log("[Publication] loadSectionsBefore: Early return (not found or at beginning)"); return; // Not found or already at beginning } @@ -371,7 +388,14 @@ !loadedAddresses.has(addr) && !existingAddresses.has(addr) ); + console.log("[Publication] loadSectionsBefore: Addresses to load:", { + total: addressesToLoad.length, + filtered: addressesToLoadFiltered.length, + addresses: addressesToLoadFiltered + }); + if (addressesToLoadFiltered.length === 0) { + console.log("[Publication] loadSectionsBefore: Early return (no addresses to load)"); return; } @@ -393,8 +417,19 @@ } } + console.log("[Publication] loadSectionsBefore: Loaded events:", { + total: newEvents.length, + valid: newEvents.filter(e => e !== null).length + }); + if (newEvents.length > 0) { + const beforeCount = leaves.length; leaves = insertEventsInOrder(newEvents, allAddresses); + console.log("[Publication] loadSectionsBefore: Updated leaves:", { + before: beforeCount, + after: leaves.length, + added: leaves.length - beforeCount + }); } isLoading = false; @@ -456,6 +491,7 @@ /** * Jumps to a specific section and loads a window of sections around it. * This allows users to jump forward to sections that haven't been rendered yet. + * Also fills in any gaps between initially loaded sections and the jump window. * * @param targetAddress The address of the section to jump to * @param windowSize Number of sections to load before and after the target (default: JUMP_WINDOW_SIZE) @@ -486,15 +522,52 @@ return; } - const startIndex = Math.max(0, targetIndex - windowSize); - const endIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); - const windowAddresses = allAddresses.slice(startIndex, endIndex + 1); - - // Filter out already loaded + // Find the last loaded section index const existingAddresses = new Set(leaves.map(leaf => leaf?.tagAddress()).filter(Boolean)); - const addressesToLoad = windowAddresses.filter(addr => - !loadedAddresses.has(addr) && !existingAddresses.has(addr) - ); + let lastLoadedIndex = -1; + for (let i = 0; i < allAddresses.length; i++) { + if (existingAddresses.has(allAddresses[i])) { + lastLoadedIndex = i; + } + } + + // Calculate jump window + const jumpStartIndex = Math.max(0, targetIndex - windowSize); + const jumpEndIndex = Math.min(allAddresses.length - 1, targetIndex + windowSize); + + // Determine if we need to fill a gap between last loaded and jump window + let gapStartIndex = -1; + let gapEndIndex = -1; + + if (lastLoadedIndex >= 0 && jumpStartIndex > lastLoadedIndex + 1) { + // There's a gap - fill it + gapStartIndex = lastLoadedIndex + 1; + gapEndIndex = jumpStartIndex - 1; + console.log(`[Publication] Gap detected: sections ${gapStartIndex}-${gapEndIndex} need to be loaded`); + } + + // Collect all addresses to load (gap + jump window) + const addressesToLoad: string[] = []; + + // Add gap addresses if needed + if (gapStartIndex >= 0 && gapEndIndex >= gapStartIndex) { + for (let i = gapStartIndex; i <= gapEndIndex; i++) { + const addr = allAddresses[i]; + if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) { + addressesToLoad.push(addr); + } + } + } + + // Add jump window addresses + for (let i = jumpStartIndex; i <= jumpEndIndex; i++) { + const addr = allAddresses[i]; + if (!loadedAddresses.has(addr) && !existingAddresses.has(addr)) { + addressesToLoad.push(addr); + } + } + + console.log(`[Publication] Jump-to-section: loading ${addressesToLoad.length} sections (gap: ${gapStartIndex >= 0 ? `${gapStartIndex}-${gapEndIndex}` : 'none'}, window: ${jumpStartIndex}-${jumpEndIndex})`); // Load events const windowEvents: Array<{ address: string; event: NDKEvent | null; index: number }> = []; @@ -523,6 +596,8 @@ if (element) { element.scrollIntoView({ behavior: 'smooth', block: 'start' }); } + // Update observer after DOM updates + updateFirstSectionObserver(); }, 100); console.log(`[Publication] Jump-to-section complete. Loaded ${windowEvents.length} sections around ${targetAddress}`); @@ -906,20 +981,109 @@ } }); - // AI-NOTE: Simple IntersectionObserver-based infinite scroll + // AI-NOTE: IntersectionObserver-based infinite scroll with debouncing // Observes sentinels and first section element for upward scrolling + // Simplified to prevent reactive loops - observer updates happen explicitly after loading + let lastUpwardLoadTime = 0; + const UPWARD_LOAD_DEBOUNCE_MS = 3000; // Prevent loading more than once per 3 seconds + let isUpdatingObserver = false; // Prevent concurrent observer updates + let isLoadingUpward = false; // Prevent multiple simultaneous upward loads + + // Store observer and state in module scope + let scrollObserver: IntersectionObserver | null = null; + let observedFirstSectionAddress: string | null = null; + let observerUpdateTimeout: number | null = null; + let ignoreNextFirstSectionIntersection = false; // Ignore first intersection when we just started observing + + /** + * Updates the observer to watch the current first section element. + * Called explicitly after loading sections before to avoid reactive loops. + */ + function updateFirstSectionObserver() { + if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { + console.log("[Publication] updateFirstSectionObserver skipped:", { + hasObserver: !!scrollObserver, + isLoading, + isUpdatingObserver, + isLoadingUpward + }); + return; + } + + // Clear any pending update + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); + observerUpdateTimeout = null; + } + + // Debounce updates + observerUpdateTimeout = window.setTimeout(() => { + if (!scrollObserver || isLoading || isUpdatingObserver || isLoadingUpward) { + console.log("[Publication] updateFirstSectionObserver timeout skipped:", { + hasObserver: !!scrollObserver, + isLoading, + isUpdatingObserver, + isLoadingUpward + }); + return; + } + + const firstSection = leaves.filter(l => l !== null)[0]; + if (!firstSection) { + console.log("[Publication] updateFirstSectionObserver: No first section found"); + return; + } + + const firstAddress = firstSection.tagAddress(); + + // Don't observe root address or if already observing this section + if (firstAddress === rootAddress || firstAddress === observedFirstSectionAddress) { + console.log("[Publication] updateFirstSectionObserver: Skipping (root or already observed):", { + firstAddress, + rootAddress, + observedFirstSectionAddress + }); + return; + } + + console.log("[Publication] updateFirstSectionObserver: Observing new first section:", firstAddress); + isUpdatingObserver = true; + + // Unobserve previous first section if it changed + if (observedFirstSectionAddress) { + const prevElement = document.getElementById(observedFirstSectionAddress); + if (prevElement && scrollObserver) { + scrollObserver.unobserve(prevElement); + console.log("[Publication] Unobserved previous first section:", observedFirstSectionAddress); + } + } + + // Observe new first section + const firstElement = document.getElementById(firstAddress); + if (firstElement && scrollObserver) { + scrollObserver.observe(firstElement); + observedFirstSectionAddress = firstAddress; + // Ignore the first intersection event (it will fire immediately if element is already in viewport) + ignoreNextFirstSectionIntersection = true; + console.log("[Publication] Now observing first section:", firstAddress, "(will ignore first intersection)"); + } else { + console.warn("[Publication] First section element not found in DOM:", firstAddress); + } + + isUpdatingObserver = false; + observerUpdateTimeout = null; + }, 800); // Increased delay to allow DOM to fully render and stabilize + } + $effect(() => { if (!hasInitialized || !publicationTree || !toc) { return; } - let observer: IntersectionObserver | null = null; let setupTimeout: number | null = null; - let updateInterval: number | null = null; - let observedFirstSection: string | null = null; const setupObserver = () => { - if (observer) { + if (scrollObserver) { return; } @@ -930,12 +1094,14 @@ return; } - observer = new IntersectionObserver( + scrollObserver = new IntersectionObserver( (entries) => { - if (isLoading || isDone) { + if (isLoading || isDone || isUpdatingObserver || isLoadingUpward) { return; } + const now = Date.now(); + for (const entry of entries) { if (!entry.isIntersecting) { continue; @@ -951,15 +1117,159 @@ loadMore(AUTO_LOAD_BATCH_SIZE); } } else if (targetId === "publication-top-sentinel") { + // Double-check isLoadingUpward here as well (defensive check) + if (isLoadingUpward) { + console.log("[Publication] Top sentinel intersection ignored (already loading upward)"); + return; + } + + // Debounce upward loads + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + console.log("[Publication] Upward load debounced, time since last:", now - lastUpwardLoadTime); + return; + } const firstSection = leaves.filter(l => l !== null)[0]; if (firstSection && firstSection.tagAddress() !== rootAddress) { - loadSectionsBefore(firstSection.tagAddress(), AUTO_LOAD_BATCH_SIZE); + const firstAddress = firstSection.tagAddress(); + console.log("[Publication] Top sentinel intersecting, loading sections before:", firstAddress); + + // Prevent multiple simultaneous upward loads + isLoadingUpward = true; + lastUpwardLoadTime = now; + + // Temporarily unobserve first section and top sentinel to prevent loop + if (observedFirstSectionAddress && scrollObserver) { + const firstElement = document.getElementById(observedFirstSectionAddress); + if (firstElement) { + scrollObserver.unobserve(firstElement); + console.log("[Publication] Unobserved first section for upward load:", observedFirstSectionAddress); + } + } + if (scrollObserver && entry.target) { + scrollObserver.unobserve(entry.target); + console.log("[Publication] Unobserved top sentinel to prevent loop"); + } + + Promise.resolve(loadSectionsBefore(firstAddress, AUTO_LOAD_BATCH_SIZE)) + .then(() => { + console.log("[Publication] Upward load complete, waiting for DOM stabilization"); + // Wait longer for DOM to fully stabilize before updating observer + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + // Re-observe top sentinel + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + console.log("[Publication] Re-observed top sentinel"); + } + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping observer update (still loading upward)"); + // Still re-observe top sentinel even if we skip the update + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + } + }, 500); + }) + .catch((error) => { + console.error("[Publication] Error loading sections before:", error); + // Re-observe top sentinel and first section even on error + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping observer update on error (still loading upward)"); + // Still re-observe top sentinel even if we skip the update + if (scrollObserver && topSentinelRef) { + scrollObserver.observe(topSentinelRef); + } + } + }, 500); + }) + .finally(() => { + isLoadingUpward = false; + console.log("[Publication] isLoadingUpward reset to false"); + }); + } else { + console.log("[Publication] Top sentinel intersecting but no valid first section or at root"); } } else { // This is the first section element + + // Double-check isLoadingUpward here as well (defensive check) + if (isLoadingUpward) { + console.log("[Publication] First section intersection ignored (already loading upward)"); + return; + } + + // Ignore first intersection event when we just started observing (prevents immediate loop) + if (ignoreNextFirstSectionIntersection) { + console.log("[Publication] Ignoring first intersection event (just started observing)"); + ignoreNextFirstSectionIntersection = false; + return; + } + + // Debounce upward loads + if ((now - lastUpwardLoadTime) < UPWARD_LOAD_DEBOUNCE_MS) { + console.log("[Publication] First section load debounced, time since last:", now - lastUpwardLoadTime); + return; + } const firstSection = leaves.filter(l => l !== null)[0]; if (firstSection && targetId === firstSection.tagAddress() && targetId !== rootAddress) { - loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE); + console.log("[Publication] First section element intersecting, loading sections before:", targetId); + + // Prevent multiple simultaneous upward loads + isLoadingUpward = true; + lastUpwardLoadTime = now; + + // Temporarily unobserve this element to prevent loop + if (scrollObserver) { + scrollObserver.unobserve(entry.target); + console.log("[Publication] Unobserved first section element for upward load:", targetId); + } + + Promise.resolve(loadSectionsBefore(targetId, AUTO_LOAD_BATCH_SIZE)) + .then(() => { + console.log("[Publication] Upward load complete (first section), waiting for DOM stabilization"); + // Wait longer for DOM to fully stabilize before updating observer + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping updateFirstSectionObserver (still loading upward)"); + } + }, 500); + }) + .catch((error) => { + console.error("[Publication] Error loading sections before:", error); + // Re-observe first section even on error + setTimeout(() => { + // Only update observer if we're not still loading upward + if (!isLoadingUpward) { + updateFirstSectionObserver(); + } else { + console.log("[Publication] Skipping updateFirstSectionObserver on error (still loading upward)"); + } + }, 500); + }) + .finally(() => { + isLoadingUpward = false; + console.log("[Publication] isLoadingUpward (first section) reset to false"); + }); + } else { + console.log("[Publication] First section element intersecting but conditions not met:", { + hasFirstSection: !!firstSection, + targetId, + firstAddress: firstSection?.tagAddress(), + isRoot: targetId === rootAddress, + matches: firstSection && targetId === firstSection.tagAddress() + }); } } break; @@ -972,60 +1282,33 @@ ); if (bottomSentinel) { - observer.observe(bottomSentinel); + scrollObserver.observe(bottomSentinel); } if (topSentinel) { - observer.observe(topSentinel); - } - }; - - // Update observer when first section changes (e.g., after jump) - const updateFirstSectionObserver = () => { - if (!observer || leaves.length === 0) { - return; - } - - const firstSection = leaves.filter(l => l !== null)[0]; - if (!firstSection) { - return; - } - - const firstAddress = firstSection.tagAddress(); - if (firstAddress === observedFirstSection || firstAddress === rootAddress) { - return; - } - - // Unobserve previous first section if it changed - if (observedFirstSection) { - const prevElement = document.getElementById(observedFirstSection); - if (prevElement) { - observer.unobserve(prevElement); - } - } - - // Observe new first section - const firstElement = document.getElementById(firstAddress); - if (firstElement) { - observer.observe(firstElement); - observedFirstSection = firstAddress; + scrollObserver.observe(topSentinel); } + + // Initial observer update after setup + setTimeout(() => { + updateFirstSectionObserver(); + }, 200); }; setupTimeout = window.setTimeout(() => { setupObserver(); - // Start updating when first section changes - updateInterval = window.setInterval(updateFirstSectionObserver, 500); }, 100); return () => { if (setupTimeout !== null) { clearTimeout(setupTimeout); } - if (updateInterval !== null) { - clearInterval(updateInterval); + if (observerUpdateTimeout !== null) { + clearTimeout(observerUpdateTimeout); } - if (observer) { - observer.disconnect(); + if (scrollObserver) { + scrollObserver.disconnect(); + scrollObserver = null; + observedFirstSectionAddress = null; } }; });