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.
1084 lines
36 KiB
1084 lines
36 KiB
<script lang="ts"> |
|
import { nostrClient } from '../../services/nostr/nostr-client.js'; |
|
import { relayManager } from '../../services/nostr/relay-manager.js'; |
|
import { sessionManager } from '../../services/auth/session-manager.js'; |
|
import FeedPost from './FeedPost.svelte'; |
|
import HighlightCard from './HighlightCard.svelte'; |
|
import ThreadDrawer from './ThreadDrawer.svelte'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
import { onMount, tick } from 'svelte'; |
|
import { KIND, getFeedKinds, getKindInfo } from '../../types/kind-lookup.js'; |
|
|
|
interface Props { |
|
singleRelay?: string; // If provided, use only this relay and disable cache |
|
} |
|
|
|
let { singleRelay }: Props = $props(); |
|
|
|
let posts = $state<NostrEvent[]>([]); |
|
let allPosts = $state<NostrEvent[]>([]); // Store all posts before filtering |
|
let highlights = $state<NostrEvent[]>([]); // Store highlight events (kind 9802) |
|
let allHighlights = $state<NostrEvent[]>([]); // Store all highlights before filtering |
|
let otherFeedEvents = $state<NostrEvent[]>([]); // Store other feed kinds (not kind 1 or 9802) |
|
let allOtherFeedEvents = $state<NostrEvent[]>([]); // Store all other feed events before filtering |
|
let loading = $state(true); |
|
let loadingMore = $state(false); |
|
let hasMore = $state(true); |
|
let oldestTimestamp = $state<number | null>(null); |
|
|
|
// List filter state |
|
let availableLists = $state<Array<{ kind: number; name: string; event: NostrEvent }>>([]); |
|
let selectedListId = $state<string | null>(null); // Format: "kind:eventId" |
|
let listFilterIds = $state<Set<string>>(new Set()); // Event IDs or pubkeys to filter by |
|
|
|
// Batch-loaded reactions: eventId -> reactions[] |
|
let reactionsMap = $state<Map<string, NostrEvent[]>>(new Map()); |
|
|
|
// 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; |
|
let subscriptionId: string | null = $state(null); |
|
let refreshInterval: ReturnType<typeof setInterval> | null = null; |
|
let subscriptionSetup = $state(false); // Track if subscription is already set up |
|
|
|
onMount(async () => { |
|
await nostrClient.initialize(); |
|
await loadUserLists(); |
|
await loadFeed(); |
|
// Set up persistent subscription for new events (only once) |
|
if (!subscriptionSetup) { |
|
setupSubscription(); |
|
setupPeriodicRefresh(); |
|
subscriptionSetup = true; |
|
} |
|
}); |
|
|
|
// Load user lists for filtering |
|
async function loadUserLists() { |
|
// Don't load user lists for single relay mode |
|
if (singleRelay) { |
|
return; |
|
} |
|
|
|
const session = sessionManager.getSession(); |
|
if (!session) return; |
|
|
|
const listKinds = [ |
|
KIND.CONTACTS, |
|
KIND.FAVORITE_RELAYS, |
|
KIND.RELAY_LIST, |
|
KIND.LOCAL_RELAYS, |
|
KIND.PIN_LIST, |
|
KIND.BOOKMARKS, |
|
KIND.INTEREST_LIST, |
|
KIND.FOLOW_SET |
|
]; |
|
|
|
try { |
|
const relays = relayManager.getProfileReadRelays(); |
|
const lists: Array<{ kind: number; name: string; event: NostrEvent }> = []; |
|
|
|
// Fetch all list types |
|
for (const kind of listKinds) { |
|
const limit = kind === KIND.FOLOW_SET ? 50 : 1; // Multiple follow sets allowed |
|
const events = await nostrClient.fetchEvents( |
|
[{ kinds: [kind], authors: [session.pubkey], limit }], |
|
relays, |
|
{ useCache: true, cacheResults: true } |
|
); |
|
|
|
const kindName = getKindName(kind); |
|
for (const event of events) { |
|
lists.push({ |
|
kind, |
|
name: `${kindName}${kind === KIND.FOLOW_SET ? ` (${new Date(event.created_at * 1000).toLocaleDateString()})` : ''}`, |
|
event |
|
}); |
|
} |
|
} |
|
|
|
availableLists = lists; |
|
} catch (error) { |
|
console.error('Error loading user lists:', error); |
|
} |
|
} |
|
|
|
function getKindName(kind: number): string { |
|
const names: Record<number, string> = { |
|
[KIND.CONTACTS]: 'Contacts', |
|
[KIND.FAVORITE_RELAYS]: 'Favorite Relays', |
|
[KIND.RELAY_LIST]: 'Relay List', |
|
[KIND.LOCAL_RELAYS]: 'Local Relays', |
|
[KIND.PIN_LIST]: 'Pin List', |
|
[KIND.BOOKMARKS]: 'Bookmarks', |
|
[KIND.INTEREST_LIST]: 'Interest List', |
|
[KIND.FOLOW_SET]: 'Follow Set' |
|
}; |
|
return names[kind] || `Kind ${kind}`; |
|
} |
|
|
|
function handleListFilterChange(listId: string | null) { |
|
selectedListId = listId; |
|
|
|
if (!listId) { |
|
// No filter selected - show all posts, highlights, and other feed events |
|
listFilterIds = new Set(); |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
return; |
|
} |
|
|
|
// Find the selected list |
|
const [kindStr, eventId] = listId.split(':'); |
|
const kind = parseInt(kindStr, 10); |
|
const list = availableLists.find(l => l.kind === kind && l.event.id === eventId); |
|
|
|
if (!list) { |
|
listFilterIds = new Set(); |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
return; |
|
} |
|
|
|
// Extract IDs from the list |
|
const ids = new Set<string>(); |
|
|
|
// For contacts and follow sets, extract pubkeys from 'p' tags |
|
if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) { |
|
for (const tag of list.event.tags) { |
|
if (tag[0] === 'p' && tag[1]) { |
|
ids.add(tag[1]); |
|
} |
|
} |
|
} else { |
|
// For other lists, extract event IDs from 'e' and 'a' tags |
|
for (const tag of list.event.tags) { |
|
if (tag[0] === 'e' && tag[1]) { |
|
ids.add(tag[1]); |
|
} else if (tag[0] === 'a' && tag[1]) { |
|
// For 'a' tags, we'd need to resolve them to event IDs |
|
// For now, we'll just use the 'a' tag value as-is |
|
// This is a simplified approach - full implementation would resolve 'a' tags |
|
ids.add(tag[1]); |
|
} |
|
} |
|
} |
|
|
|
listFilterIds = ids; |
|
|
|
// Filter posts, highlights, and other feed events |
|
if (kind === KIND.CONTACTS || kind === KIND.FOLOW_SET) { |
|
// Filter by author pubkey |
|
posts = allPosts.filter(post => ids.has(post.pubkey)); |
|
highlights = allHighlights.filter(highlight => ids.has(highlight.pubkey)); |
|
otherFeedEvents = allOtherFeedEvents.filter(event => ids.has(event.pubkey)); |
|
} else { |
|
// Filter by event ID |
|
posts = allPosts.filter(post => ids.has(post.id)); |
|
highlights = allHighlights.filter(highlight => ids.has(highlight.id)); |
|
otherFeedEvents = allOtherFeedEvents.filter((event: NostrEvent) => ids.has(event.id)); |
|
} |
|
} |
|
|
|
// Apply filter when allPosts, allHighlights, or allOtherFeedEvents changes |
|
$effect(() => { |
|
if (selectedListId) { |
|
handleListFilterChange(selectedListId); |
|
} else { |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
} |
|
}); |
|
|
|
// Cleanup subscription on unmount |
|
$effect(() => { |
|
return () => { |
|
if (subscriptionId) { |
|
nostrClient.unsubscribe(subscriptionId); |
|
subscriptionId = null; |
|
} |
|
if (refreshInterval) { |
|
clearInterval(refreshInterval); |
|
refreshInterval = null; |
|
} |
|
subscriptionSetup = false; |
|
}; |
|
}); |
|
|
|
// 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); |
|
} |
|
if (subscriptionId) { |
|
nostrClient.unsubscribe(subscriptionId); |
|
subscriptionId = null; |
|
} |
|
if (refreshInterval) { |
|
clearInterval(refreshInterval); |
|
refreshInterval = null; |
|
} |
|
}; |
|
}); |
|
|
|
// Set up persistent subscription for real-time updates |
|
function setupSubscription() { |
|
if (subscriptionId) { |
|
// Already subscribed |
|
return; |
|
} |
|
|
|
// Don't set up subscription for single relay mode |
|
if (singleRelay) { |
|
return; |
|
} |
|
|
|
const relays = relayManager.getFeedReadRelays(); |
|
const feedKinds = getFeedKinds(); |
|
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 })); |
|
|
|
// Subscribe to new kind 1 events and kind 9802 highlights |
|
subscriptionId = nostrClient.subscribe( |
|
filters, |
|
relays, |
|
(event: NostrEvent) => { |
|
// Only add events that are newer than what we already have |
|
// Check against all feed event types |
|
const existingIds = new Set([ |
|
...posts.map(p => p.id), |
|
...highlights.map(h => h.id), |
|
...otherFeedEvents.map(e => e.id) |
|
]); |
|
if (!existingIds.has(event.id)) { |
|
handleUpdate([event]); |
|
} |
|
}, |
|
(relay: string) => { |
|
console.debug(`[FeedPage] Subscription EOSE from ${relay}`); |
|
} |
|
); |
|
|
|
console.log(`[FeedPage] Set up persistent subscription for new events (ID: ${subscriptionId})`); |
|
} |
|
|
|
// Set up periodic refresh to ensure we get new events even if subscription fails |
|
function setupPeriodicRefresh() { |
|
if (refreshInterval) { |
|
return; // Already set up |
|
} |
|
|
|
// Don't set up periodic refresh for single relay mode |
|
if (singleRelay) { |
|
return; |
|
} |
|
|
|
// Refresh every 30 seconds |
|
refreshInterval = setInterval(async () => { |
|
try { |
|
const relays = relayManager.getFeedReadRelays(); |
|
|
|
// Get the newest event's timestamp from all feed event types to only fetch newer events |
|
const allFeedEvents = [...posts, ...highlights, ...otherFeedEvents]; |
|
const newestTimestamp = allFeedEvents.length > 0 |
|
? Math.max(...allFeedEvents.map(e => e.created_at)) |
|
: Math.floor(Date.now() / 1000) - 60; // Last minute if no events |
|
|
|
const feedKinds = getFeedKinds(); |
|
const filters = feedKinds.map(kind => ({ |
|
kinds: [kind], |
|
limit: 50, |
|
since: newestTimestamp + 1 // Only get events newer than what we have |
|
})); |
|
|
|
// Fetch new events (without cache to ensure we query relays) |
|
const events = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: false, // Don't use cache for refresh - always query relays |
|
cacheResults: true, |
|
timeout: 10000 |
|
} |
|
); |
|
|
|
// Check for new events |
|
const existingIds = new Set(posts.map(p => p.id)); |
|
const newEvents = events.filter(e => !existingIds.has(e.id)); |
|
|
|
if (newEvents.length > 0) { |
|
console.log(`[FeedPage] Periodic refresh found ${newEvents.length} new events`); |
|
handleUpdate(newEvents); |
|
} |
|
} catch (error) { |
|
console.debug('[FeedPage] Periodic refresh error:', error); |
|
} |
|
}, 30000); // 30 seconds |
|
|
|
console.log('[FeedPage] Set up periodic refresh (every 30 seconds)'); |
|
} |
|
|
|
// 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(); |
|
|
|
// Use single relay if provided, otherwise use normal relay list |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
|
|
// Load all feed kinds |
|
const feedKinds = getFeedKinds(); |
|
const filters = feedKinds.map(kind => ({ kinds: [kind], limit: 20 })); |
|
|
|
// For single relay mode, load from cache first for immediate display |
|
// Then query the relay in background to get fresh data |
|
let events: NostrEvent[] = []; |
|
|
|
if (singleRelay) { |
|
// Step 1: Load from cache immediately (fast, shows something right away) |
|
const cachedEvents = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: true, // Use cache for fast initial load |
|
cacheResults: false, // Don't cache again |
|
timeout: 2000 // Short timeout for cache |
|
} |
|
); |
|
|
|
// Show cached data immediately if available |
|
if (cachedEvents.length > 0) { |
|
events = cachedEvents; |
|
console.log(`[FeedPage] Loaded ${cachedEvents.length} cached events from ${singleRelay}`); |
|
// Process cached events immediately so they show up |
|
// (will be processed below) |
|
} |
|
|
|
// Step 2: Ensure relay is connected and query for fresh data |
|
// If we have cached data, do this in background. Otherwise, wait for it. |
|
const queryPromise = (async () => { |
|
try { |
|
console.log(`[FeedPage] Single relay mode: ensuring ${singleRelay} is connected...`); |
|
// Force connection to the relay |
|
await nostrClient.addRelay(singleRelay); |
|
// Give it a moment to establish connection |
|
await new Promise(resolve => setTimeout(resolve, 1000)); |
|
|
|
// Query relay for fresh data |
|
const freshEvents = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: false, // Force query relay |
|
cacheResults: true, // Cache the results |
|
timeout: 15000 |
|
} |
|
); |
|
|
|
console.log(`[FeedPage] Fresh query returned ${freshEvents.length} events from ${singleRelay}`); |
|
|
|
// Update with fresh data |
|
if (freshEvents.length > 0) { |
|
const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]); |
|
const trulyNew = freshEvents.filter(e => !existingIds.has(e.id)); |
|
if (trulyNew.length > 0 || freshEvents.length !== events.length) { |
|
handleUpdate(freshEvents); |
|
} |
|
} |
|
|
|
return freshEvents; |
|
} catch (error) { |
|
console.warn(`[FeedPage] Failed to query relay ${singleRelay}:`, error); |
|
// If query fails but we have cached data, that's okay - keep showing cached data |
|
return []; |
|
} |
|
})(); |
|
|
|
// If we don't have cached data, wait for the relay query |
|
if (events.length === 0) { |
|
const freshEvents = await queryPromise; |
|
if (freshEvents.length > 0) { |
|
events = freshEvents; |
|
} |
|
} else { |
|
// If we have cached data, query in background (don't await) |
|
queryPromise.catch(() => { |
|
// Already logged error above |
|
}); |
|
} |
|
} else { |
|
// Normal mode: use cache first, then query relays |
|
events = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: true, // Use cache for fast initial load |
|
cacheResults: true, // Cache results |
|
timeout: 15000 |
|
} |
|
); |
|
|
|
console.log(`[FeedPage] Loaded ${events.length} events from relays`); |
|
|
|
// Also immediately query relays to ensure we get fresh data in background |
|
nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: false, // Force query relays |
|
cacheResults: true, // Cache results |
|
timeout: 15000 |
|
} |
|
).then((newEvents) => { |
|
console.log(`[FeedPage] Background query returned ${newEvents.length} events`); |
|
// Only update if we got new events that aren't already in posts |
|
if (newEvents.length > 0) { |
|
const existingIds = new Set([...posts.map(p => p.id), ...highlights.map(h => h.id)]); |
|
const trulyNew = newEvents.filter(e => !existingIds.has(e.id)); |
|
if (trulyNew.length > 0) { |
|
handleUpdate(trulyNew); |
|
} |
|
} |
|
}).catch(error => { |
|
console.warn('[FeedPage] Background relay query error:', error); |
|
}); |
|
} |
|
|
|
// Separate events by kind - we'll handle all showInFeed kinds |
|
const postsList = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); |
|
const highlightsList = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); |
|
// Store other feed kinds separately for now (we'll create cards for them) |
|
const newOtherFeedEvents = events.filter((e: NostrEvent) => |
|
e.kind !== KIND.SHORT_TEXT_NOTE && |
|
e.kind !== KIND.HIGHLIGHTED_ARTICLE && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Sort by created_at descending and deduplicate |
|
const uniquePostsMap = new Map<string, NostrEvent>(); |
|
for (const event of postsList) { |
|
if (!uniquePostsMap.has(event.id)) { |
|
uniquePostsMap.set(event.id, event); |
|
} |
|
} |
|
const uniquePosts = Array.from(uniquePostsMap.values()); |
|
const sortedPosts = uniquePosts.sort((a, b) => b.created_at - a.created_at); |
|
allPosts = sortedPosts; |
|
|
|
const uniqueHighlightsMap = new Map<string, NostrEvent>(); |
|
for (const event of highlightsList) { |
|
if (!uniqueHighlightsMap.has(event.id)) { |
|
uniqueHighlightsMap.set(event.id, event); |
|
} |
|
} |
|
const uniqueHighlights = Array.from(uniqueHighlightsMap.values()); |
|
const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at); |
|
allHighlights = sortedHighlights; |
|
|
|
// Store other feed events |
|
const uniqueOtherMap = new Map<string, NostrEvent>(); |
|
for (const event of newOtherFeedEvents) { |
|
if (!uniqueOtherMap.has(event.id)) { |
|
uniqueOtherMap.set(event.id, event); |
|
} |
|
} |
|
const uniqueOther = Array.from(uniqueOtherMap.values()); |
|
const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at); |
|
allOtherFeedEvents = sortedOther; |
|
|
|
// Always set posts, highlights, and other feed events immediately, even if empty |
|
// This ensures cached data shows up right away |
|
// Apply filter if one is selected |
|
if (selectedListId) { |
|
handleListFilterChange(selectedListId); |
|
} else { |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
} |
|
|
|
// Set loading to false immediately after showing cached data |
|
// This allows the UI to render while fresh data loads in background |
|
loading = false; |
|
|
|
console.log(`[FeedPage] Loaded ${sortedPosts.length} posts and ${sortedHighlights.length} highlights`); |
|
|
|
if (sortedPosts.length > 0 || sortedHighlights.length > 0) { |
|
const allTimestamps = [...sortedPosts.map(e => e.created_at), ...sortedHighlights.map(e => e.created_at)]; |
|
oldestTimestamp = Math.min(...allTimestamps); |
|
// Batch load reactions for all posts |
|
await loadReactionsForPosts(sortedPosts); |
|
} else { |
|
console.log('[FeedPage] No events found. Relays:', relays); |
|
} |
|
|
|
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(); |
|
|
|
// Use single relay if provided, otherwise use normal relay list |
|
const relays = singleRelay ? [singleRelay] : relayManager.getFeedReadRelays(); |
|
|
|
const feedKinds = getFeedKinds(); |
|
const filters = feedKinds.map(kind => ({ |
|
kinds: [kind], |
|
limit: 20, |
|
until: oldestTimestamp || undefined |
|
})); |
|
|
|
// For single relay mode, try cache first, then query relay |
|
let events: NostrEvent[] = []; |
|
|
|
if (singleRelay) { |
|
// Try cache first |
|
const cachedEvents = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: true, |
|
cacheResults: false, |
|
timeout: 2000 |
|
} |
|
); |
|
|
|
if (cachedEvents.length > 0) { |
|
events = cachedEvents; |
|
} |
|
|
|
// Query relay in background for fresh data |
|
nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: false, |
|
cacheResults: true, |
|
timeout: 10000 |
|
} |
|
).then((freshEvents) => { |
|
if (freshEvents.length > 0) { |
|
const existingIds = new Set([...allPosts.map(p => p.id), ...allHighlights.map(h => h.id)]); |
|
const uniqueNewPosts = freshEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE && !existingIds.has(e.id)); |
|
const uniqueNewHighlights = freshEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE && !existingIds.has(e.id)); |
|
|
|
if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) { |
|
handleUpdate(freshEvents); |
|
} |
|
} |
|
}).catch(error => { |
|
console.warn('[FeedPage] Background query error:', error); |
|
}); |
|
} else { |
|
events = await nostrClient.fetchEvents( |
|
filters, |
|
relays, |
|
{ |
|
useCache: true, |
|
cacheResults: true, |
|
timeout: 10000 |
|
} |
|
); |
|
} |
|
|
|
if (events.length === 0) { |
|
hasMore = false; |
|
return; |
|
} |
|
|
|
// Separate events by kind |
|
const newPosts = events.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); |
|
const newHighlights = events.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); |
|
const newOtherFeedEvents = events.filter((e: NostrEvent) => |
|
e.kind !== KIND.SHORT_TEXT_NOTE && |
|
e.kind !== KIND.HIGHLIGHTED_ARTICLE && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Filter out duplicates |
|
const existingPostIds = new Set(allPosts.map(p => p.id)); |
|
const existingHighlightIds = new Set(allHighlights.map(h => h.id)); |
|
const existingOtherIds = new Set(allOtherFeedEvents.map((e: NostrEvent) => e.id)); |
|
const uniqueNewPosts = newPosts.filter(e => !existingPostIds.has(e.id)); |
|
const uniqueNewHighlights = newHighlights.filter(e => !existingHighlightIds.has(e.id)); |
|
const uniqueNewOther = newOtherFeedEvents.filter((e: NostrEvent) => !existingOtherIds.has(e.id)); |
|
|
|
if (uniqueNewPosts.length > 0 || uniqueNewHighlights.length > 0) { |
|
if (uniqueNewPosts.length > 0) { |
|
const sorted = uniqueNewPosts.sort((a, b) => b.created_at - a.created_at); |
|
allPosts = [...allPosts, ...sorted]; |
|
// Batch load reactions for new posts |
|
await loadReactionsForPosts(sorted); |
|
} |
|
|
|
if (uniqueNewHighlights.length > 0) { |
|
const sorted = uniqueNewHighlights.sort((a, b) => b.created_at - a.created_at); |
|
allHighlights = [...allHighlights, ...sorted]; |
|
} |
|
|
|
if (uniqueNewOther.length > 0) { |
|
const sorted = uniqueNewOther.sort((a, b) => b.created_at - a.created_at); |
|
allOtherFeedEvents = [...allOtherFeedEvents, ...sorted]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
} |
|
|
|
// Apply filter if one is selected |
|
if (selectedListId) { |
|
handleListFilterChange(selectedListId); |
|
} else { |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
} |
|
|
|
const allNewTimestamps = [...uniqueNewPosts.map(e => e.created_at), ...uniqueNewHighlights.map(e => e.created_at)]; |
|
const oldest = Math.min(...allNewTimestamps); |
|
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 and loops |
|
function handleUpdate(updated: NostrEvent[]) { |
|
if (!updated || updated.length === 0) return; |
|
|
|
// Deduplicate incoming updates before adding to pending |
|
// Check against all feed event types |
|
const existingIds = new Set([ |
|
...allPosts.map(p => p.id), |
|
...allHighlights.map(h => h.id), |
|
...allOtherFeedEvents.map(e => e.id) |
|
]); |
|
const newUpdates = updated.filter(e => e && e.id && !existingIds.has(e.id)); |
|
|
|
if (newUpdates.length === 0) { |
|
return; // All duplicates, skip silently |
|
} |
|
|
|
// Also deduplicate within pendingUpdates |
|
const pendingIds = new Set(pendingUpdates.map(e => e.id)); |
|
const trulyNew = newUpdates.filter(e => !pendingIds.has(e.id)); |
|
|
|
if (trulyNew.length === 0) { |
|
return; // Already in pending, skip silently |
|
} |
|
|
|
pendingUpdates.push(...trulyNew); |
|
|
|
if (updateTimeout) { |
|
clearTimeout(updateTimeout); |
|
} |
|
|
|
// Batch updates every 500ms to prevent rapid re-renders |
|
updateTimeout = setTimeout(() => { |
|
if (pendingUpdates.length === 0) { |
|
return; |
|
} |
|
|
|
// Final deduplication check against all feed event types (may have changed) |
|
const currentIds = new Set([ |
|
...allPosts.map(p => p.id), |
|
...allHighlights.map(h => h.id), |
|
...allOtherFeedEvents.map(e => e.id) |
|
]); |
|
const newEvents = pendingUpdates.filter(e => e && e.id && !currentIds.has(e.id)); |
|
|
|
if (newEvents.length === 0) { |
|
pendingUpdates = []; |
|
return; |
|
} |
|
|
|
console.log(`[FeedPage] Processing ${newEvents.length} new events, existing: ${allPosts.length}`); |
|
|
|
// Separate events by kind |
|
const newPosts = newEvents.filter(e => e.kind === KIND.SHORT_TEXT_NOTE); |
|
const newHighlights = newEvents.filter(e => e.kind === KIND.HIGHLIGHTED_ARTICLE); |
|
const newOtherFeedEvents = newEvents.filter((e: NostrEvent) => |
|
e.kind !== KIND.SHORT_TEXT_NOTE && |
|
e.kind !== KIND.HIGHLIGHTED_ARTICLE && |
|
getKindInfo(e.kind).showInFeed === true |
|
); |
|
|
|
// Merge and sort posts, then deduplicate by ID |
|
if (newPosts.length > 0) { |
|
const mergedPosts = [...allPosts, ...newPosts]; |
|
const uniquePostsMap = new Map<string, NostrEvent>(); |
|
for (const event of mergedPosts) { |
|
if (event && event.id && !uniquePostsMap.has(event.id)) { |
|
uniquePostsMap.set(event.id, event); |
|
} |
|
} |
|
const uniquePosts = Array.from(uniquePostsMap.values()); |
|
const sortedPosts = uniquePosts.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Only update if we actually have new events to prevent loops |
|
if (sortedPosts.length > allPosts.length || sortedPosts.some((e, i) => e.id !== allPosts[i]?.id)) { |
|
allPosts = sortedPosts; |
|
} |
|
} |
|
|
|
// Merge and sort highlights, then deduplicate by ID |
|
if (newHighlights.length > 0) { |
|
const mergedHighlights = [...allHighlights, ...newHighlights]; |
|
const uniqueHighlightsMap = new Map<string, NostrEvent>(); |
|
for (const event of mergedHighlights) { |
|
if (event && event.id && !uniqueHighlightsMap.has(event.id)) { |
|
uniqueHighlightsMap.set(event.id, event); |
|
} |
|
} |
|
const uniqueHighlights = Array.from(uniqueHighlightsMap.values()); |
|
const sortedHighlights = uniqueHighlights.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Only update if we actually have new events to prevent loops |
|
if (sortedHighlights.length > allHighlights.length || sortedHighlights.some((e, i) => e.id !== allHighlights[i]?.id)) { |
|
allHighlights = sortedHighlights; |
|
} |
|
} |
|
|
|
// Merge and sort other feed events, then deduplicate by ID |
|
if (newOtherFeedEvents.length > 0) { |
|
const mergedOther = [...allOtherFeedEvents, ...newOtherFeedEvents]; |
|
const uniqueOtherMap = new Map<string, NostrEvent>(); |
|
for (const event of mergedOther) { |
|
if (event && event.id && !uniqueOtherMap.has(event.id)) { |
|
uniqueOtherMap.set(event.id, event); |
|
} |
|
} |
|
const uniqueOther = Array.from(uniqueOtherMap.values()); |
|
const sortedOther = uniqueOther.sort((a, b) => b.created_at - a.created_at); |
|
|
|
// Only update if we actually have new events to prevent loops |
|
if (sortedOther.length > allOtherFeedEvents.length || sortedOther.some((e, i) => e.id !== allOtherFeedEvents[i]?.id)) { |
|
allOtherFeedEvents = sortedOther; |
|
} |
|
} |
|
|
|
// Apply filter if one is selected |
|
if (selectedListId) { |
|
handleListFilterChange(selectedListId); |
|
} else { |
|
posts = [...allPosts]; |
|
highlights = [...allHighlights]; |
|
otherFeedEvents = [...allOtherFeedEvents]; |
|
} |
|
|
|
console.debug(`[FeedPage] Updated: ${allPosts.length} posts, ${allHighlights.length} highlights`); |
|
|
|
pendingUpdates = []; |
|
}, 500); |
|
} |
|
|
|
// Batch load reactions for multiple posts at once |
|
async function loadReactionsForPosts(postsToLoad: NostrEvent[]) { |
|
if (postsToLoad.length === 0) return; |
|
|
|
try { |
|
const reactionRelays = relayManager.getProfileReadRelays(); |
|
const eventIds = postsToLoad.map(p => p.id); |
|
|
|
// Use single relay if provided, otherwise use normal reaction relays |
|
const relaysForReactions = singleRelay ? [singleRelay] : reactionRelays; |
|
|
|
// For single relay mode, disable cache completely |
|
const useCache = !singleRelay; |
|
const cacheResults = !singleRelay; |
|
|
|
// Batch fetch all reactions for all posts in one query |
|
const allReactions = await nostrClient.fetchEvents( |
|
[ |
|
{ kinds: [KIND.REACTION], '#e': eventIds, limit: 1000 }, |
|
{ kinds: [KIND.REACTION], '#E': eventIds, limit: 1000 } |
|
], |
|
relaysForReactions, |
|
{ useCache, cacheResults, timeout: 10000 } |
|
); |
|
|
|
// Group reactions by event ID |
|
const newReactionsMap = new Map<string, NostrEvent[]>(); |
|
for (const reaction of allReactions) { |
|
// Find which event(s) this reaction is for |
|
const eTags = reaction.tags.filter(t => (t[0] === 'e' || t[0] === 'E') && t[1]); |
|
for (const tag of eTags) { |
|
const eventId = tag[1]; |
|
if (eventIds.includes(eventId)) { |
|
if (!newReactionsMap.has(eventId)) { |
|
newReactionsMap.set(eventId, []); |
|
} |
|
newReactionsMap.get(eventId)!.push(reaction); |
|
} |
|
} |
|
} |
|
|
|
// Merge with existing reactions |
|
for (const [eventId, reactions] of newReactionsMap.entries()) { |
|
const existing = reactionsMap.get(eventId) || []; |
|
const combined = [...existing, ...reactions]; |
|
// Deduplicate by reaction ID |
|
const unique = Array.from(new Map(combined.map(r => [r.id, r])).values()); |
|
reactionsMap.set(eventId, unique); |
|
} |
|
} catch (error) { |
|
console.error('[FeedPage] Error batch loading reactions:', error); |
|
} |
|
} |
|
</script> |
|
|
|
<div class="feed-page"> |
|
{#if !loading && availableLists.length > 0 && !singleRelay} |
|
<div class="feed-filter"> |
|
<label for="list-filter" class="filter-label">Filter by list:</label> |
|
<select |
|
id="list-filter" |
|
bind:value={selectedListId} |
|
onchange={(e) => handleListFilterChange((e.target as HTMLSelectElement).value || null)} |
|
class="filter-select" |
|
> |
|
<option value="">All Posts</option> |
|
{#each availableLists as list} |
|
<option value="{list.kind}:{list.event.id}">{list.name}</option> |
|
{/each} |
|
</select> |
|
</div> |
|
{/if} |
|
|
|
{#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 posts.length === 0 && highlights.length === 0 && otherFeedEvents.length === 0} |
|
<div class="empty-state"> |
|
<p class="text-fog-text dark:text-fog-dark-text"> |
|
{#if selectedListId} |
|
No posts found in selected list. |
|
{:else} |
|
No posts found. Check back later! |
|
{/if} |
|
</p> |
|
</div> |
|
{:else} |
|
<div class="feed-posts"> |
|
{#each [...posts, ...highlights, ...otherFeedEvents].sort((a, b) => b.created_at - a.created_at) as event (event.id)} |
|
{#if event.kind === KIND.HIGHLIGHTED_ARTICLE} |
|
<HighlightCard highlight={event} onOpenEvent={openDrawer} /> |
|
{:else} |
|
<FeedPost post={event} onOpenEvent={openDrawer} reactions={reactionsMap.get(event.id)} /> |
|
{/if} |
|
{/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%; |
|
} |
|
|
|
.feed-filter { |
|
margin-bottom: 1.5rem; |
|
display: flex; |
|
align-items: center; |
|
gap: 0.75rem; |
|
} |
|
|
|
.filter-label { |
|
font-size: 0.875rem; |
|
font-weight: 500; |
|
color: var(--fog-text, #1f2937); |
|
} |
|
|
|
:global(.dark) .filter-label { |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
.filter-select { |
|
padding: 0.5rem 0.75rem; |
|
border: 1px solid var(--fog-border, #e5e7eb); |
|
border-radius: 0.375rem; |
|
background: var(--fog-post, #ffffff); |
|
color: var(--fog-text, #1f2937); |
|
font-size: 0.875rem; |
|
cursor: pointer; |
|
min-width: 200px; |
|
} |
|
|
|
.filter-select:focus { |
|
outline: none; |
|
border-color: var(--fog-accent, #64748b); |
|
box-shadow: 0 0 0 3px rgba(100, 116, 139, 0.1); |
|
} |
|
|
|
:global(.dark) .filter-select { |
|
background: var(--fog-dark-post, #1f2937); |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text, #f9fafb); |
|
} |
|
|
|
:global(.dark) .filter-select:focus { |
|
border-color: var(--fog-dark-accent, #94a3b8); |
|
box-shadow: 0 0 0 3px rgba(148, 163, 184, 0.1); |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.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); |
|
} |
|
</style>
|
|
|