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.
 
 
 
 
 

774 lines
28 KiB

<script lang="ts">
import FeedPost from './FeedPost.svelte';
import ReplaceableEventCard from './ReplaceableEventCard.svelte';
import ThreadDrawer from './ThreadDrawer.svelte';
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 { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { getFeedKinds, getReplaceableKinds } from '../../types/kind-lookup.js';
let posts = $state<NostrEvent[]>([]);
let replaceableEvents = $state<NostrEvent[]>([]);
let loading = $state(true);
let hasMore = $state(true);
let loadingMore = $state(false);
let newPostsCount = $state(0);
let lastPostId = $state<string | null>(null);
let showOPsOnly = $state(false);
let showResponsesToMe = $state(false);
let isLoadingFeed = false; // Guard to prevent concurrent loads
let scrollTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingNewPosts = $state<NostrEvent[]>([]); // Store new posts until user clicks button
let pendingNewReplaceable = $state<NostrEvent[]>([]); // Store new replaceable events
let loadedParentQuotedEvents = $state<Map<string, NostrEvent>>(new Map()); // Store loaded parent/quoted events separately (doesn't trigger feed re-render)
// Thread drawer state
let drawerOpen = $state(false);
let selectedEvent = $state<NostrEvent | null>(null);
// Get current user pubkey
let currentPubkey = $state<string | null>(sessionManager.getCurrentPubkey());
// Subscribe to session changes
$effect(() => {
const unsubscribe = sessionManager.session.subscribe(() => {
currentPubkey = sessionManager.getCurrentPubkey();
});
return unsubscribe;
});
onMount(() => {
nostrClient.initialize().then(() => {
loadFeed();
});
// Set up infinite scroll
window.addEventListener('scroll', handleScroll);
return () => {
window.removeEventListener('scroll', handleScroll);
if (scrollTimeout) {
clearTimeout(scrollTimeout);
scrollTimeout = null;
}
};
});
async function loadFeed(reset = true) {
// Prevent concurrent loads
if (isLoadingFeed) {
return;
}
isLoadingFeed = true;
if (reset) {
loading = true;
posts = [];
hasMore = true;
} else {
loadingMore = true;
}
try {
const oldestTimestamp = posts.length > 0
? Math.min(...posts.map(p => p.created_at))
: undefined;
const relays = relayManager.getFeedReadRelays();
const feedKinds = getFeedKinds();
const replaceableKinds = getReplaceableKinds();
// Phase 1: Fetch all feed kinds - one request per relay, sent in parallel
// Update cache in background (10 second timeout), view updates when cache is done
const feedFilter = feedKinds.length > 0 ? [{
kinds: feedKinds,
limit: 100,
...(oldestTimestamp ? { until: oldestTimestamp } : {})
}] : [];
let allFeedEvents: NostrEvent[] = [];
if (feedFilter.length > 0) {
// Debounce onUpdate to prevent rapid-fire updates
let updateTimeout: ReturnType<typeof setTimeout> | null = null;
let pendingUpdate: NostrEvent[] | null = null;
// Capture reset value to avoid stale closure
const isReset = reset;
const debouncedOnUpdate = (updated: NostrEvent[]) => {
pendingUpdate = updated;
if (updateTimeout) {
clearTimeout(updateTimeout);
}
updateTimeout = setTimeout(() => {
if (!pendingUpdate) return;
const updated = pendingUpdate;
pendingUpdate = null;
// Don't update the feed automatically - only store new posts for manual update
// This prevents feed jumping and allows user to control when to refresh
// Works the same whether drawer is open or closed - events go to pending arrays
const updatedRegularPosts = updated.filter((e: NostrEvent) => e.kind === 1);
const updatedReplaceable = updated.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
const updatedOtherFeedEvents = updated.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
// NEVER update the feed automatically from onUpdate callback
// This prevents feed jumping - user must click button to see updates
// Only store new posts in pending arrays (works for both drawer open and closed)
if (!isReset) {
// Store new posts in pending arrays instead of updating feed automatically
const existingIds = new Set([...posts, ...pendingNewPosts].map(p => p.id));
const allNewEvents = [...updatedRegularPosts, ...updatedOtherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Add to pending posts instead of directly to feed
const existingPendingIds = new Set(pendingNewPosts.map(p => p.id));
const trulyNewPosts = newPosts.filter(e => !existingPendingIds.has(e.id));
if (trulyNewPosts.length > 0) {
pendingNewPosts = [...pendingNewPosts, ...trulyNewPosts];
// Update counter for new posts (only if drawer is closed, to avoid confusion)
if (!drawerOpen) {
if (lastPostId) {
const newCount = trulyNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount += newCount;
}
} else {
// If no lastPostId, count all new posts
newPostsCount += trulyNewPosts.length;
}
}
}
}
// Store new replaceable events in pending array
const existingReplaceableIds = new Set([...replaceableEvents, ...pendingNewReplaceable].map(e => e.id));
const newReplaceable = updatedReplaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
pendingNewReplaceable = [...pendingNewReplaceable, ...newReplaceable];
}
}
allFeedEvents = updated;
}, 1000); // Debounce to 1 second to reduce update frequency
};
// For initial load (reset), don't use onUpdate to prevent incremental updates
// Wait for the full fetch to complete before displaying anything
const fetchOptions = reset
? {
useCache: true,
cacheResults: true,
timeout: 10000
// No onUpdate during initial load to prevent scrolling
}
: {
useCache: true,
cacheResults: true,
timeout: 10000,
onUpdate: debouncedOnUpdate
};
// Get cached events first for immediate display, then refresh in background
// For initial load, wait for full fetch without onUpdate to prevent scrolling
const cachedEvents = await nostrClient.fetchEvents(
feedFilter,
relays,
fetchOptions
);
// Process cached events
// Load ALL feed events into posts array (including replies and kind 1111)
// Filtering happens client-side in getFilteredPosts() based on showOPsOnly checkbox
const regularPosts = cachedEvents.filter((e: NostrEvent) => e.kind === 1);
const replaceable = cachedEvents.filter((e: NostrEvent) =>
replaceableKinds.includes(e.kind) &&
e.tags.some(t => t[0] === 'd')
);
// Include all other feed kinds (including kind 1111 comments)
const otherFeedEvents = cachedEvents.filter((e: NostrEvent) =>
e.kind !== 1 &&
!replaceableKinds.includes(e.kind) &&
feedKinds.includes(e.kind)
);
if (reset) {
// For initial load, batch all updates at once to prevent scrolling
// Load ALL events into posts array - filtering happens client-side
// Only sort if we have posts to prevent unnecessary re-renders
if (regularPosts.length > 0 || otherFeedEvents.length > 0) {
posts = sortPosts([...regularPosts, ...otherFeedEvents]);
}
replaceableEvents = replaceable.sort((a, b) => b.created_at - a.created_at);
lastPostId = regularPosts.length > 0 ? regularPosts[0].id : null;
} else {
// For infinite scroll (loading more), add new posts directly to feed
// This is a user-initiated action, so update immediately
// BUT: Don't update if drawer is open - freeze the feed
if (!drawerOpen) {
// Don't re-sort existing posts - just append new ones to prevent jumping
const existingIds = new Set(posts.map(p => p.id));
const allNewEvents = [...regularPosts, ...otherFeedEvents];
const newPosts = allNewEvents.filter(e => !existingIds.has(e.id));
if (newPosts.length > 0) {
// Sort only the new posts, then append to existing (preserve existing order)
const sortedNewPosts = sortPosts(newPosts);
posts = [...posts, ...sortedNewPosts];
}
const existingReplaceableIds = new Set(replaceableEvents.map(e => e.id));
const newReplaceable = replaceable.filter(e => !existingReplaceableIds.has(e.id));
if (newReplaceable.length > 0) {
// Append new replaceable events without re-sorting existing ones
replaceableEvents = [...replaceableEvents, ...newReplaceable.sort((a, b) => b.created_at - a.created_at)];
}
}
}
allFeedEvents = cachedEvents;
}
// For initial load, wait a moment to ensure all data is processed before showing feed
// This prevents scrolling issues from incremental updates
if (reset) {
// Small delay to ensure DOM is ready and prevent scroll jumping
await new Promise(resolve => setTimeout(resolve, 100));
}
// Background refresh is handled automatically by fetchEvents with useCache: true
// For initial load, we don't use onUpdate, so background refresh won't cause scrolling
// Phase 2: Fetch secondary kinds (reactions, zaps) for displayed events
// One request per relay with all filters, sent in parallel, update cache in background (10s timeout)
// Only fetch if we're not in a loading state to prevent excessive requests
// Don't fetch during initial load to prevent scrolling
if (!isLoadingFeed && !loading && !loadingMore && !reset) {
const displayedEventIds = [...posts, ...replaceableEvents].map(e => e.id);
if (displayedEventIds.length > 0) {
// Fetch reactions (kind 7) and zap receipts (kind 9735) for displayed events
const secondaryFilter = [{
kinds: [7, 9735],
'#e': displayedEventIds.slice(0, 100) // Limit to avoid huge requests
}];
// Fetch in background, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
nostrClient.fetchEvents(
secondaryFilter,
relays,
{
useCache: true,
cacheResults: true,
timeout: 10000
}
).catch(err => {
console.error('Error fetching secondary events:', err);
});
}
// Phase 3: Fetch kind 0 profiles, statuses, and activity data for npubs in feed
// Batch fetch all at once to prevent individual requests from ProfileBadge components
// This runs on both initial load and background refresh
const uniquePubkeys = new Set<string>();
for (const event of [...posts, ...replaceableEvents]) {
uniquePubkeys.add(event.pubkey);
}
if (uniquePubkeys.size > 0) {
const pubkeysArray = Array.from(uniquePubkeys).slice(0, 100); // Limit to avoid huge requests
// Batch fetch profiles (kind 0)
const profileFilter = [{
kinds: [0],
authors: pubkeysArray
}];
// Batch fetch user statuses (kind 30315)
const statusFilter = [{
kinds: [30315],
authors: pubkeysArray,
'#d': ['general'],
limit: 1
} as any];
// Batch fetch activity data (recent events from each pubkey)
const activityFilter = [{
authors: pubkeysArray,
kinds: [0, 1, 7, 11, 1111],
limit: 1
}];
// Fetch all in parallel, update cache, view will update automatically via cache
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
// Run in background to not block initial render
Promise.all([
nostrClient.fetchEvents(profileFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(statusFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
}),
nostrClient.fetchEvents(activityFilter, relays, {
useCache: true,
cacheResults: true,
timeout: 10000
})
]).catch(err => {
console.error('Error fetching profiles/statuses/activity:', err);
});
}
// Fetch missing parent events (batch, one request per relay)
const allEventIds = new Set(posts.map(e => e.id));
const missingParentIds = new Set<string>();
for (const event of posts) {
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && !allEventIds.has(parentId)) {
missingParentIds.add(parentId);
}
}
if (missingParentIds.size > 0) {
const parentIdsArray = Array.from(missingParentIds).slice(0, 50);
// Don't use onUpdate here to prevent triggering UI updates that cause jumping
nostrClient.fetchEvents(
[{ kinds: [1], ids: parentIdsArray }],
relays,
{ useCache: true, cacheResults: true, timeout: 10000 }
).catch(err => {
console.error('Error fetching parent events:', err);
});
}
}
hasMore = allFeedEvents.length >= 100;
} catch (error) {
console.error('Error loading feed:', error);
} finally {
loading = false;
loadingMore = false;
isLoadingFeed = false;
}
}
function handleScroll() {
// Throttle scroll handler to prevent doom loops
if (scrollTimeout) {
return;
}
scrollTimeout = setTimeout(() => {
scrollTimeout = null;
}, 500); // Increase throttle to 500ms to reduce frequency
if (loadingMore || !hasMore || isLoadingFeed) return;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
// Load more when within 500px of bottom (increased threshold to reduce triggers)
if (scrollTop + windowHeight >= documentHeight - 500) {
loadFeed(false);
}
}
function sortPosts(events: NostrEvent[]): NostrEvent[] {
// Build thread structure
const eventMap = new Map<string, NostrEvent>();
const replyMap = new Map<string, string[]>(); // parentId -> childIds[]
const rootEvents: NostrEvent[] = [];
// First pass: build maps
for (const event of events) {
eventMap.set(event.id, event);
// Check if this is a reply
const replyTag = event.tags.find((t) => t[0] === 'e' && t[3] === 'reply');
const parentId = replyTag?.[1];
if (parentId && eventMap.has(parentId)) {
// This is a reply to an event we have
if (!replyMap.has(parentId)) {
replyMap.set(parentId, []);
}
replyMap.get(parentId)!.push(event.id);
} else {
// This is a root event (not a reply, or parent not loaded)
rootEvents.push(event);
}
}
// Second pass: recursively collect all events in thread order
const result: NostrEvent[] = [];
const processed = new Set<string>();
function addThread(event: NostrEvent) {
if (processed.has(event.id)) return;
processed.add(event.id);
result.push(event);
// Add all replies to this event
const replies = replyMap.get(event.id) || [];
const replyEvents = replies
.map(id => eventMap.get(id))
.filter((e): e is NostrEvent => e !== undefined)
.sort((a, b) => a.created_at - b.created_at); // Sort replies chronologically
for (const reply of replyEvents) {
addThread(reply);
}
}
// Add all root events sorted by newest first
rootEvents.sort((a, b) => b.created_at - a.created_at);
for (const root of rootEvents) {
addThread(root);
}
return result;
}
function handleShowNewPosts() {
// Don't update feed while drawer is open - freeze in place
if (drawerOpen) return;
// Merge pending new posts into the feed
if (pendingNewPosts.length > 0) {
// Sort the merged array to maintain proper order
const merged = [...pendingNewPosts, ...posts];
posts = sortPosts(merged);
pendingNewPosts = [];
}
if (pendingNewReplaceable.length > 0) {
replaceableEvents = [...pendingNewReplaceable, ...replaceableEvents].sort((a, b) => b.created_at - a.created_at);
pendingNewReplaceable = [];
}
// Scroll to top and reset new posts count
window.scrollTo({ top: 0, behavior: 'smooth' });
newPostsCount = 0;
lastPostId = posts.length > 0 ? posts[0].id : null;
}
function isReply(post: NostrEvent): boolean {
// Check if this event references another event (reply, quote, or replaceable event reference)
// Filter out anything with "e", "a", or "q" tags
// Check for "e" tag (references another event - reply)
if (post.tags.some((t) => t[0] === 'e' && t[1] !== undefined && t[1] !== post.id)) {
return true;
}
// Check for "a" tag (references a replaceable event)
if (post.tags.some((t) => t[0] === 'a' && t[1] !== undefined)) {
return true;
}
// Check for "q" tag (quotes another event)
if (post.tags.some((t) => t[0] === 'q' && t[1] !== undefined)) {
return true;
}
return false;
}
/**
* Filter posts client-side based on showOPsOnly and showResponsesToMe checkboxes.
* This is purely reactive - no queries are triggered.
* All events are already loaded into the posts array from cache.
*/
function getFilteredPosts(): NostrEvent[] {
let filtered = posts;
// Filter for responses to me (events where current user is in "p" tag)
if (showResponsesToMe && currentPubkey) {
filtered = filtered.filter(post => {
// Check if current user's pubkey is in any "p" tag
return post.tags.some((t) => t[0] === 'p' && t[1] === currentPubkey);
});
}
// Filter for OPs only (original posts, no replies, quotes, or references)
if (showOPsOnly) {
filtered = filtered.filter(post => {
// Filter out all kind 1111 events (comments)
if (post.kind === 1111) return false;
// Filter out any event that is a reply, quote, or reference
if (isReply(post)) return false;
// Keep everything else (original posts)
return true;
});
}
return filtered;
}
function openThreadDrawer(event: NostrEvent, e?: MouseEvent) {
// Don't open drawer if clicking on interactive elements (but allow the wrapper itself)
if (e) {
const target = e.target as HTMLElement;
const wrapper = target.closest('.post-wrapper');
// If the target itself is a button, link, or has role="button" (but not the wrapper)
if (target.tagName === 'BUTTON' || target.tagName === 'A' ||
(target.getAttribute('role') === 'button' && target !== wrapper)) {
return;
}
// Check if clicking inside a button or link (but not the wrapper)
const button = target.closest('button');
const link = target.closest('a');
if ((button && button !== wrapper) || (link && link !== wrapper)) {
return;
}
}
selectedEvent = event;
drawerOpen = true;
}
function closeThreadDrawer() {
drawerOpen = false;
selectedEvent = null;
// Events that arrived while drawer was open are already in pendingNewPosts/pendingNewReplaceable
// Update the counter now that drawer is closed so user sees the notification
if (pendingNewPosts.length > 0 && lastPostId) {
const newCount = pendingNewPosts.filter(e => e.id !== lastPostId).length;
if (newCount > 0) {
newPostsCount = newCount;
}
} else if (pendingNewPosts.length > 0) {
newPostsCount = pendingNewPosts.length;
}
}
// Use $derived to make this reactive and prevent infinite loops
let allFeedItems = $derived.by(() => {
const items: Array<{ id: string; event: NostrEvent; type: 'post' | 'replaceable'; created_at: number }> = [];
// Add filtered posts
const filteredPosts = getFilteredPosts();
for (const post of filteredPosts) {
items.push({
id: post.id,
event: post,
type: 'post',
created_at: post.created_at
});
}
// Add replaceable events
for (const event of replaceableEvents) {
items.push({
id: event.id,
event: event,
type: 'replaceable',
created_at: event.created_at
});
}
// Sort by created_at, newest first
return items.sort((a, b) => b.created_at - a.created_at);
});
</script>
<div class="Feed-feed">
<div class="feed-header mb-4">
<h1 class="text-2xl font-bold mb-4">Feed</h1>
<div class="feed-controls flex flex-wrap items-center gap-2 sm:gap-4">
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showOPsOnly}
class="checkbox"
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show OPs only</span>
</label>
{#if currentPubkey}
<label class="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
bind:checked={showResponsesToMe}
class="checkbox"
/>
<span class="text-sm text-fog-text dark:text-fog-dark-text">Show responses to me</span>
</label>
{/if}
</div>
</div>
{#if loading}
<p class="text-fog-text-light dark:text-fog-dark-text-light">Loading feed...</p>
{:else if posts.length === 0 && replaceableEvents.length === 0}
<p class="text-fog-text-light dark:text-fog-dark-text-light">No posts yet. Be the first to post!</p>
{:else}
{#if newPostsCount > 0}
<div class="new-posts-indicator mb-4">
<button
onclick={handleShowNewPosts}
class="px-4 py-2 bg-fog-accent dark:bg-fog-dark-accent text-white rounded hover:opacity-90 text-sm"
>
{newPostsCount} new {newPostsCount === 1 ? 'post' : 'posts'} - Click to view
</button>
</div>
{/if}
<div class="posts-list">
{#each allFeedItems as item (item.id)}
{#if item.type === 'post'}
{@const parentId = item.event.tags.find((t) => t[0] === 'e' && t[3] === 'reply')?.[1]}
{@const parentEvent = parentId ? (posts.find(p => p.id === parentId) || loadedParentQuotedEvents.get(parentId)) : undefined}
{@const quotedId = item.event.tags.find((t) => t[0] === 'q')?.[1]}
{@const quotedEvent = quotedId ? (posts.find(p => p.id === quotedId) || loadedParentQuotedEvents.get(quotedId)) : undefined}
<div
data-post-id={item.event.id}
class="post-wrapper"
class:keyboard-selected={false}
onclick={(e) => openThreadDrawer(item.event, e)}
role="button"
tabindex="0"
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openThreadDrawer(item.event);
}
}}
>
<FeedPost
post={item.event}
parentEvent={parentEvent}
quotedEvent={quotedEvent}
onParentLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
onQuotedLoaded={(event) => {
// Store loaded parent/quoted events in separate map to prevent feed re-rendering
// NEVER add to main posts array - this causes feed jumping
if (!loadedParentQuotedEvents.has(event.id)) {
loadedParentQuotedEvents.set(event.id, event);
}
}}
/>
</div>
{:else if item.type === 'replaceable'}
<div data-event-id={item.event.id} class="post-wrapper" class:keyboard-selected={false}>
<ReplaceableEventCard event={item.event} />
</div>
{/if}
{/each}
</div>
{#if loadingMore}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">Loading more...</p>
{/if}
{#if !hasMore && allFeedItems.length > 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">No more posts</p>
{/if}
{#if (showOPsOnly || showResponsesToMe) && getFilteredPosts().length === 0 && posts.length > 0 && replaceableEvents.length === 0}
<p class="text-center text-fog-text-light dark:text-fog-dark-text-light mt-4">
{#if showResponsesToMe && showOPsOnly}
No original posts responding to you found. Try unchecking the filters.
{:else if showResponsesToMe}
No responses to you found. Try unchecking "Show responses to me".
{:else}
No original posts found. Try unchecking "Show OPs only".
{/if}
</p>
{/if}
{/if}
</div>
<ThreadDrawer
opEvent={selectedEvent}
isOpen={drawerOpen}
onClose={closeThreadDrawer}
/>
<style>
.Feed-feed {
max-width: var(--content-width);
margin: 0 auto;
padding: 1rem;
}
.feed-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.feed-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem; /* row-gap column-gap for better wrapping */
}
@media (max-width: 768px) {
.feed-controls {
gap: 0.5rem 0.75rem; /* Smaller gaps on mobile */
}
}
.checkbox {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.post-wrapper {
transition: background 0.2s;
cursor: pointer;
}
.post-wrapper:hover {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
}
:global(.dark) .post-wrapper:hover {
background: var(--fog-dark-highlight, #374151);
}
.post-wrapper.keyboard-selected {
background: var(--fog-highlight, #f3f4f6);
border-radius: 0.25rem;
padding: 0.25rem;
margin: -0.25rem;
}
:global(.dark) .post-wrapper.keyboard-selected {
background: var(--fog-dark-highlight, #374151);
}
</style>