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.
 
 
 
 
 

271 lines
7.4 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, tick } from 'svelte';
let posts = $state<NostrEvent[]>([]);
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let oldestTimestamp = $state<number | null>(null);
// Drawer state for viewing parent/quoted events
let drawerOpen = $state(false);
let drawerEvent = $state<NostrEvent | null>(null);
// Debounce updates to prevent rapid re-renders
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdates: NostrEvent[] = [];
function openDrawer(event: NostrEvent) {
drawerEvent = event;
drawerOpen = true;
}
function closeDrawer() {
drawerOpen = false;
drawerEvent = null;
}
let sentinelElement = $state<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
onMount(async () => {
await nostrClient.initialize();
await loadFeed();
});
// 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);
};
});
// Cleanup on unmount
$effect(() => {
return () => {
if (observer) {
observer.disconnect();
}
if (updateTimeout) {
clearTimeout(updateTimeout);
}
};
});
// Set up observer when sentinel element is available
$effect(() => {
if (sentinelElement && !loading && !observer) {
observer = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && hasMore && !loadingMore) {
loadMore();
}
}, { threshold: 0.1 });
observer.observe(sentinelElement);
return () => {
if (observer) {
observer.disconnect();
observer = null;
}
};
}
});
async function loadFeed() {
loading = true;
try {
const config = nostrClient.getConfig();
const relays = relayManager.getFeedReadRelays();
// Load initial feed - cache first, then background refresh
const filters = [{ kinds: [1], limit: 20 }];
const events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: true,
onUpdate: handleUpdate,
timeout: 10000
}
);
// Sort by created_at descending
const sorted = events.sort((a, b) => b.created_at - a.created_at);
posts = sorted;
if (sorted.length > 0) {
oldestTimestamp = Math.min(...sorted.map(e => e.created_at));
}
hasMore = events.length >= 20;
} catch (error) {
console.error('Error loading feed:', error);
} finally {
loading = false;
}
}
async function loadMore() {
if (loadingMore || !hasMore) return;
loadingMore = true;
try {
const config = nostrClient.getConfig();
const relays = relayManager.getFeedReadRelays();
const filters = [{
kinds: [1],
limit: 20,
until: oldestTimestamp || undefined
}];
const events = await nostrClient.fetchEvents(
filters,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
);
if (events.length === 0) {
hasMore = false;
return;
}
// Filter out duplicates
const existingIds = new Set(posts.map(p => p.id));
const newEvents = events.filter(e => !existingIds.has(e.id));
if (newEvents.length > 0) {
const sorted = newEvents.sort((a, b) => b.created_at - a.created_at);
posts = [...posts, ...sorted];
const oldest = Math.min(...newEvents.map(e => e.created_at));
if (oldest < (oldestTimestamp || Infinity)) {
oldestTimestamp = oldest;
}
hasMore = events.length >= 20;
} else if (events.length > 0) {
// All events were duplicates, but we got some results
// This might mean we've reached the end, or we need to adjust the timestamp
if (oldestTimestamp) {
// Try moving the timestamp forward slightly to avoid getting the same results
oldestTimestamp = oldestTimestamp - 1;
hasMore = events.length >= 20;
} else {
hasMore = false;
}
} else {
// No events returned at all
hasMore = false;
}
} catch (error) {
console.error('Error loading more:', error);
} finally {
loadingMore = false;
}
}
// Debounced update handler to prevent rapid re-renders
function handleUpdate(updated: NostrEvent[]) {
console.log(`[FeedPage] handleUpdate called with ${updated.length} events, current posts: ${posts.length}`);
pendingUpdates.push(...updated);
if (updateTimeout) {
clearTimeout(updateTimeout);
}
// Batch updates every 500ms
updateTimeout = setTimeout(() => {
if (pendingUpdates.length === 0) return;
const existingIds = new Set(posts.map(p => p.id));
const newEvents = pendingUpdates.filter(e => !existingIds.has(e.id));
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${posts.length}`);
if (newEvents.length > 0) {
// Merge and sort
const merged = [...posts, ...newEvents];
const sorted = merged.sort((a, b) => b.created_at - a.created_at);
console.log(`[FeedPage] Setting posts to ${sorted.length} events`);
posts = sorted;
}
pendingUpdates = [];
}, 500);
}
</script>
<div class="feed-page">
{#if loading}
<div class="loading-state">
<p class="text-fog-text dark:text-fog-dark-text">Loading feed...</p>
</div>
{:else if posts.length === 0}
<div class="empty-state">
<p class="text-fog-text dark:text-fog-dark-text">No posts found. Check back later!</p>
</div>
{:else}
<div class="feed-posts">
{#each posts as post (post.id)}
<FeedPost post={post} onOpenEvent={openDrawer} />
{/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 {
padding: 2rem;
text-align: center;
}
.feed-posts {
display: flex;
flex-direction: column;
gap: 1rem;
}
.feed-sentinel {
padding: 2rem;
text-align: center;
min-height: 100px;
}
</style>