You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
645 lines
20 KiB
645 lines
20 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import FeedPost from './FeedPost.svelte'; |
|
import ThreadDrawer from './ThreadDrawer.svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { onMount } from 'svelte'; |
|
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; |
|
import { getRecentFeedEvents } from '../../services/cache/event-cache.js'; |
|
|
|
interface Props { |
|
singleRelay?: string; // If provided, use only this relay and disable cache |
|
} |
|
|
|
let { singleRelay }: Props = $props(); |
|
|
|
let events = $state<NostrEvent[]>([]); |
|
let loading = $state(true); |
|
let loadingMore = $state(false); |
|
let hasMore = $state(true); |
|
let oldestTimestamp = $state<number | null>(null); |
|
let relayError = $state<string | null>(null); |
|
|
|
// Batch-loaded parent and quoted events |
|
let parentEventsMap = $state<Map<string, NostrEvent>>(new Map()); |
|
let quotedEventsMap = $state<Map<string, NostrEvent>>(new Map()); |
|
|
|
// Drawer state |
|
let drawerOpen = $state(false); |
|
let drawerEvent = $state<NostrEvent | null>(null); |
|
|
|
let sentinelElement = $state<HTMLElement | null>(null); |
|
let observer: IntersectionObserver | null = null; |
|
let subscriptionId: string | null = $state(null); |
|
let isMounted = $state(true); |
|
let loadingParents = $state(false); // Guard to prevent concurrent parent/quoted event loads |
|
let loadingFeed = $state(false); // Guard to prevent concurrent feed loads |
|
let pendingSubscriptionEvents = $state<NostrEvent[]>([]); // Batch subscription events |
|
let subscriptionBatchTimeout: ReturnType<typeof setTimeout> | null = null; |
|
let loadMoreTimeout: ReturnType<typeof setTimeout> | null = null; // Debounce loadMore calls |
|
let lastLoadMoreTime = $state<number>(0); // Track last loadMore call time |
|
|
|
function openDrawer(event: NostrEvent) { |
|
drawerEvent = event; |
|
drawerOpen = true; |
|
} |
|
|
|
function closeDrawer() { |
|
drawerOpen = false; |
|
drawerEvent = null; |
|
} |
|
|
|
onMount(() => { |
|
isMounted = true; |
|
(async () => { |
|
await nostrClient.initialize(); |
|
if (!isMounted) return; |
|
|
|
// Load cached feed events immediately (15 minute cache) |
|
await loadCachedFeed(); |
|
|
|
if (!isMounted) return; |
|
// Then fetch fresh data in the background |
|
await loadFeed(); |
|
if (!isMounted) return; |
|
setupSubscription(); |
|
setupObserver(); |
|
})(); |
|
|
|
return () => { |
|
isMounted = false; |
|
if (subscriptionId) { |
|
nostrClient.unsubscribe(subscriptionId); |
|
subscriptionId = null; |
|
} |
|
if (observer) { |
|
observer.disconnect(); |
|
observer = null; |
|
} |
|
if (subscriptionBatchTimeout) { |
|
clearTimeout(subscriptionBatchTimeout); |
|
subscriptionBatchTimeout = null; |
|
} |
|
if (loadMoreTimeout) { |
|
clearTimeout(loadMoreTimeout); |
|
loadMoreTimeout = null; |
|
} |
|
pendingSubscriptionEvents = []; |
|
}; |
|
}); |
|
|
|
// Listen for custom event from EmbeddedEvent components |
|
$effect(() => { |
|
const handleOpenEvent = (e: CustomEvent) => { |
|
if (e.detail?.event) { |
|
openDrawer(e.detail.event); |
|
} |
|
}; |
|
|
|
window.addEventListener('openEventInDrawer', handleOpenEvent as EventListener); |
|
|
|
return () => { |
|
window.removeEventListener('openEventInDrawer', handleOpenEvent as EventListener); |
|
}; |
|
}); |
|
|
|
function processSubscriptionBatch() { |
|
if (!isMounted || pendingSubscriptionEvents.length === 0 || loadingFeed) { |
|
pendingSubscriptionEvents = []; |
|
subscriptionBatchTimeout = null; |
|
return; |
|
} |
|
|
|
// Get current events snapshot to avoid race conditions |
|
const currentEvents = events; |
|
const currentEventIds = new Set(currentEvents.map(e => e.id)); |
|
|
|
// Filter out discussion threads and deduplicate within pending events |
|
const seenInPending = new Set<string>(); |
|
const newEvents: NostrEvent[] = []; |
|
|
|
for (const event of pendingSubscriptionEvents) { |
|
// Skip discussion threads |
|
if (event.kind === KIND.DISCUSSION_THREAD) continue; |
|
|
|
// Skip if already in current events or already seen in this batch |
|
if (!currentEventIds.has(event.id) && !seenInPending.has(event.id)) { |
|
newEvents.push(event); |
|
seenInPending.add(event.id); |
|
} |
|
} |
|
|
|
if (newEvents.length === 0) { |
|
pendingSubscriptionEvents = []; |
|
subscriptionBatchTimeout = null; |
|
return; |
|
} |
|
|
|
// Create a completely new events array with all unique events |
|
// Use a Map to ensure uniqueness by event ID (last one wins if duplicates somehow exist) |
|
const eventsMap = new Map<string, NostrEvent>(); |
|
|
|
// Add all current events first |
|
for (const event of currentEvents) { |
|
eventsMap.set(event.id, event); |
|
} |
|
|
|
// Add new events (will overwrite if somehow duplicate, but shouldn't happen) |
|
for (const event of newEvents) { |
|
eventsMap.set(event.id, event); |
|
} |
|
|
|
// Convert to array and sort |
|
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
|
|
pendingSubscriptionEvents = []; |
|
subscriptionBatchTimeout = null; |
|
} |
|
|
|
function setupSubscription() { |
|
if (subscriptionId || singleRelay) return; |
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 })); |
|
|
|
subscriptionId = nostrClient.subscribe( |
|
filters, |
|
relays, |
|
(event: NostrEvent) => { |
|
if (!isMounted || event.kind === KIND.DISCUSSION_THREAD || loadingFeed) return; |
|
|
|
// Add to pending batch |
|
pendingSubscriptionEvents.push(event); |
|
|
|
// Clear existing timeout |
|
if (subscriptionBatchTimeout) { |
|
clearTimeout(subscriptionBatchTimeout); |
|
} |
|
|
|
// Process batch after a short delay (debounce rapid updates) |
|
subscriptionBatchTimeout = setTimeout(() => { |
|
processSubscriptionBatch(); |
|
}, 100); // 100ms debounce |
|
}, |
|
() => {} |
|
); |
|
} |
|
|
|
function setupObserver() { |
|
if (!sentinelElement || loading || observer) return; |
|
|
|
observer = new IntersectionObserver((entries) => { |
|
if (entries[0].isIntersecting && hasMore && !loadingMore) { |
|
// Debounce rapid scroll events - clear any pending loadMore call |
|
if (loadMoreTimeout) { |
|
clearTimeout(loadMoreTimeout); |
|
} |
|
|
|
// Wait 300ms before actually loading more to batch rapid scroll events |
|
loadMoreTimeout = setTimeout(() => { |
|
if (hasMore && !loadingMore && isMounted) { |
|
loadMore(); |
|
} |
|
loadMoreTimeout = null; |
|
}, 300); |
|
} |
|
}, { threshold: 0.1, rootMargin: '100px' }); // Add rootMargin to trigger slightly earlier |
|
|
|
observer.observe(sentinelElement); |
|
} |
|
|
|
async function loadCachedFeed() { |
|
if (!isMounted || singleRelay) return; |
|
|
|
try { |
|
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); |
|
// Load events cached within the last 15 minutes |
|
const cachedEvents = await getRecentFeedEvents(feedKinds, 15 * 60 * 1000, 50); |
|
|
|
if (cachedEvents.length > 0 && isMounted) { |
|
// Filter to only showInFeed kinds and exclude kind 11 |
|
const filteredEvents = cachedEvents.filter((e: NostrEvent) => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Deduplicate |
|
const uniqueMap = new Map<string, NostrEvent>(); |
|
for (const event of filteredEvents) { |
|
if (!uniqueMap.has(event.id)) { |
|
uniqueMap.set(event.id, event); |
|
} |
|
} |
|
|
|
const sortedEvents = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
|
|
if (sortedEvents.length > 0) { |
|
events = sortedEvents; |
|
oldestTimestamp = Math.min(...events.map(e => e.created_at)); |
|
// Load parent/quoted events in background, don't await |
|
// Only load if not already loading to prevent cascading fetches |
|
if (!loadingParents) { |
|
loadParentAndQuotedEvents(events).catch(err => { |
|
console.error('Error loading parent/quoted events from cache:', err); |
|
}); |
|
} |
|
// Don't set loading to false here - let loadFeed() handle that |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error loading cached feed:', error); |
|
// Don't set loading to false - let loadFeed() handle that |
|
} |
|
} |
|
|
|
async function loadFeed() { |
|
if (!isMounted || loadingFeed) return; // Prevent concurrent loads |
|
|
|
loadingFeed = true; |
|
// Only show loading spinner if we don't have cached events |
|
const hasCachedEvents = events.length > 0; |
|
if (!hasCachedEvents) { |
|
loading = true; |
|
} |
|
relayError = null; |
|
|
|
try { |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
|
|
if (singleRelay) { |
|
try { |
|
const relay = await nostrClient.getRelay(singleRelay); |
|
if (!relay) { |
|
relayError = `Relay ${singleRelay} is unavailable or returned an error.`; |
|
loading = false; |
|
return; |
|
} |
|
} catch (error) { |
|
relayError = `Failed to connect to relay ${singleRelay}: ${error instanceof Error ? error.message : 'Unknown error'}`; |
|
loading = false; |
|
return; |
|
} |
|
} |
|
|
|
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 })); |
|
|
|
const fetchOptions = singleRelay ? { |
|
relayFirst: true, |
|
useCache: false, |
|
cacheResults: false, |
|
timeout: 15000 |
|
} : { |
|
relayFirst: true, |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 3000 |
|
}; |
|
|
|
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions); |
|
|
|
if (!isMounted) return; |
|
|
|
// Filter to only showInFeed kinds and exclude kind 11 |
|
const filteredEvents = fetchedEvents.filter((e: NostrEvent) => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Deduplicate and merge with existing events |
|
const existingIds = new Set(events.map(e => e.id)); |
|
const uniqueMap = new Map<string, NostrEvent>(); |
|
|
|
// Add existing events first |
|
for (const event of events) { |
|
uniqueMap.set(event.id, event); |
|
} |
|
|
|
// Add new events |
|
for (const event of filteredEvents) { |
|
if (!uniqueMap.has(event.id)) { |
|
uniqueMap.set(event.id, event); |
|
} |
|
} |
|
|
|
events = Array.from(uniqueMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
|
|
if (events.length > 0) { |
|
oldestTimestamp = Math.min(...events.map(e => e.created_at)); |
|
// Load parent/quoted events in background, don't await |
|
// Only load if not already loading to prevent cascading fetches |
|
if (!loadingParents) { |
|
loadParentAndQuotedEvents(events).catch(err => { |
|
console.error('Error loading parent/quoted events:', err); |
|
}); |
|
} |
|
} |
|
|
|
hasMore = fetchedEvents.length >= 20; |
|
} catch (error) { |
|
console.error('Error loading feed:', error); |
|
} finally { |
|
loading = false; |
|
loadingFeed = false; |
|
} |
|
} |
|
|
|
async function loadMore() { |
|
// Double-check guards to prevent concurrent calls |
|
if (!isMounted || loadingMore || !hasMore || loadingFeed) { |
|
return; |
|
} |
|
|
|
// Prevent rapid successive calls - minimum 1 second between loads |
|
const now = Date.now(); |
|
if (now - lastLoadMoreTime < 1000) { |
|
return; |
|
} |
|
|
|
lastLoadMoreTime = now; |
|
loadingMore = true; |
|
try { |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds().filter(kind => kind !== KIND.DISCUSSION_THREAD); |
|
const filters = feedKinds.map(kind => ({ |
|
kinds: [kind], |
|
limit: 20, |
|
until: oldestTimestamp || undefined |
|
})); |
|
|
|
const fetchOptions = singleRelay ? { |
|
relayFirst: true, |
|
useCache: false, |
|
cacheResults: false, |
|
timeout: 3000 |
|
} : { |
|
relayFirst: true, |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 3000 |
|
}; |
|
|
|
const fetchedEvents = await nostrClient.fetchEvents(filters, relays, fetchOptions); |
|
|
|
if (!isMounted) return; |
|
|
|
if (fetchedEvents.length === 0) { |
|
hasMore = false; |
|
return; |
|
} |
|
|
|
const filteredEvents = fetchedEvents.filter((e: NostrEvent) => |
|
e.kind !== KIND.DISCUSSION_THREAD && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Use Map-based deduplication to ensure no duplicates |
|
const existingIds = new Set(events.map(e => e.id)); |
|
const eventsMap = new Map<string, NostrEvent>(); |
|
|
|
// Add all existing events first |
|
for (const event of events) { |
|
eventsMap.set(event.id, event); |
|
} |
|
|
|
// Track which events are actually new |
|
const newEvents: NostrEvent[] = []; |
|
|
|
// Add new events (will only add if not already present) |
|
for (const event of filteredEvents) { |
|
if (!eventsMap.has(event.id)) { |
|
eventsMap.set(event.id, event); |
|
newEvents.push(event); |
|
} |
|
} |
|
|
|
// Only update if we have new events |
|
if (newEvents.length > 0) { |
|
// Convert to array and sort |
|
events = Array.from(eventsMap.values()).sort((a, b) => b.created_at - a.created_at); |
|
const allTimestamps = events.map(e => e.created_at); |
|
oldestTimestamp = Math.min(...allTimestamps); |
|
// Load parent/quoted events in background, don't await |
|
// Only load if not already loading to prevent cascading fetches |
|
if (!loadingParents) { |
|
loadParentAndQuotedEvents(newEvents).catch(err => { |
|
console.error('Error loading parent/quoted events for new events:', err); |
|
}); |
|
} |
|
} |
|
|
|
hasMore = fetchedEvents.length >= 20; |
|
} catch (error) { |
|
console.error('Error loading more:', error); |
|
} finally { |
|
loadingMore = false; |
|
} |
|
} |
|
|
|
async function loadParentAndQuotedEvents(postsToLoad: NostrEvent[]) { |
|
if (!isMounted || postsToLoad.length === 0 || loadingParents) return; |
|
|
|
loadingParents = true; |
|
try { |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
const parentEventIds = new Set<string>(); |
|
const quotedEventIds = new Set<string>(); |
|
|
|
for (const post of postsToLoad) { |
|
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
|
if (replyTag && replyTag[1]) { |
|
parentEventIds.add(replyTag[1]); |
|
} else { |
|
const rootId = post.tags.find((t) => t[0] === 'root')?.[1]; |
|
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); |
|
if (eTag && eTag[1]) { |
|
parentEventIds.add(eTag[1]); |
|
} |
|
} |
|
|
|
const quotedTag = post.tags.find((t) => t[0] === 'q'); |
|
if (quotedTag && quotedTag[1]) { |
|
quotedEventIds.add(quotedTag[1]); |
|
} |
|
} |
|
|
|
const allEventIds = [...parentEventIds, ...quotedEventIds]; |
|
if (allEventIds.length === 0) return; |
|
|
|
const fetchedEvents = await nostrClient.fetchEvents( |
|
[{ kinds: [KIND.SHORT_TEXT_NOTE], ids: allEventIds }], |
|
relays, |
|
singleRelay ? { |
|
relayFirst: true, |
|
useCache: false, |
|
cacheResults: false, |
|
timeout: 3000 |
|
} : { |
|
relayFirst: true, |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 3000 |
|
} |
|
); |
|
|
|
if (!isMounted) return; |
|
|
|
const eventsById = new Map<string, NostrEvent>(); |
|
for (const event of fetchedEvents) { |
|
eventsById.set(event.id, event); |
|
} |
|
|
|
for (const post of postsToLoad) { |
|
const replyTag = post.tags.find((t) => t[0] === 'e' && t[3] === 'reply'); |
|
let parentId: string | undefined; |
|
if (replyTag && replyTag[1]) { |
|
parentId = replyTag[1]; |
|
} else { |
|
const rootId = post.tags.find((t) => t[0] === 'root')?.[1]; |
|
const eTag = post.tags.find((t) => t[0] === 'e' && t[1] !== rootId && t[1] !== post.id); |
|
parentId = eTag?.[1]; |
|
} |
|
if (parentId && eventsById.has(parentId)) { |
|
parentEventsMap.set(post.id, eventsById.get(parentId)!); |
|
} |
|
|
|
const quotedTag = post.tags.find((t) => t[0] === 'q'); |
|
if (quotedTag && quotedTag[1] && eventsById.has(quotedTag[1])) { |
|
quotedEventsMap.set(post.id, eventsById.get(quotedTag[1])!); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('[FeedPage] Error loading parent/quoted events:', error); |
|
} finally { |
|
loadingParents = false; |
|
} |
|
} |
|
</script> |
|
|
|
<div class="feed-page"> |
|
{#if singleRelay} |
|
<div class="relay-info"> |
|
<p class="relay-info-text"> |
|
Showing feed from: <code class="relay-url">{singleRelay}</code> |
|
</p> |
|
</div> |
|
{/if} |
|
|
|
{#if loading} |
|
<div class="loading-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p> |
|
</div> |
|
{:else if relayError} |
|
<div class="error-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text error-message">{relayError}</p> |
|
</div> |
|
{:else if events.length === 0} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text">No posts found.</p> |
|
</div> |
|
{:else} |
|
<div class="feed-posts"> |
|
{#each events as event (event.id)} |
|
<FeedPost |
|
post={event} |
|
onOpenEvent={openDrawer} |
|
parentEvent={parentEventsMap.get(event.id)} |
|
quotedEvent={quotedEventsMap.get(event.id)} |
|
/> |
|
{/each} |
|
</div> |
|
|
|
{#if drawerOpen && drawerEvent} |
|
<ThreadDrawer opEvent={drawerEvent} isOpen={drawerOpen} onClose={closeDrawer} /> |
|
{/if} |
|
|
|
<div id="feed-sentinel" class="feed-sentinel" bind:this={sentinelElement}> |
|
{#if loadingMore} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading more...</p> |
|
{:else if hasMore} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">Scroll for more</p> |
|
{:else} |
|
<p class="text-fog-text-light dark:text-fog-dark-text-light">No more posts</p> |
|
{/if} |
|
</div> |
|
{/if} |
|
</div> |
|
|
|
<style> |
|
.feed-page { |
|
max-width: 100%; |
|
} |
|
|
|
.loading-state, |
|
.empty-state, |
|
.error-state { |
|
padding: 2rem; |
|
text-align: center; |
|
} |
|
|
|
.error-message { |
|
font-weight: 600; |
|
color: var(--fog-accent, #64748b); |
|
} |
|
|
|
:global(.dark) .error-message { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
.feed-posts { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 1rem; |
|
} |
|
|
|
.feed-sentinel { |
|
padding: 2rem; |
|
text-align: center; |
|
min-height: 100px; |
|
} |
|
|
|
.relay-info { |
|
margin-bottom: 1.5rem; |
|
padding: 1rem; |
|
background: var(--fog-highlight, #f3f4f6); |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
} |
|
|
|
:global(.dark) .relay-info { |
|
background: var(--fog-dark-highlight, #374151); |
|
border-color: var(--fog-dark-border, #475569); |
|
} |
|
|
|
.relay-info-text { |
|
margin: 0; |
|
font-size: 0.875rem; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .relay-info-text { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.relay-url { |
|
font-family: monospace; |
|
font-size: 0.875rem; |
|
background: var(--fog-post, #ffffff); |
|
padding: 0.25rem 0.5rem; |
|
border-radius: 0.25rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
} |
|
|
|
:global(.dark) .relay-url { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
} |
|
|
|
/* Limit all images to 600px wide */ |
|
:global(.feed-page img) { |
|
max-width: 600px; |
|
height: auto; |
|
} |
|
</style>
|
|
|