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