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

<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>