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

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