diff --git a/src/components/feed/Feed.tsx b/src/components/feed/Feed.tsx index e2d4cc0..664f52a 100644 --- a/src/components/feed/Feed.tsx +++ b/src/components/feed/Feed.tsx @@ -34,6 +34,8 @@ export function Feed() { const connect = useFeedStore((s) => s.connect); const loadCachedFeed = useFeedStore((s) => s.loadCachedFeed); const loadFeed = useFeedStore((s) => s.loadFeed); + const loadOlderNotes = useFeedStore((s) => s.loadOlderNotes); + const loadingOlder = useFeedStore((s) => s.loadingOlder); const trendingNotes = useFeedStore((s) => s.trendingNotes); const trendingLoading = useFeedStore((s) => s.trendingLoading); const loadTrendingFeed = useFeedStore((s) => s.loadTrendingFeed); @@ -166,6 +168,18 @@ export function Feed() { if (focusedNoteIndex >= 0) virtualizer.scrollToIndex(focusedNoteIndex, { align: "center" }); }, [focusedNoteIndex, virtualizer]); + // Infinite scroll (Global tab only): when the user nears the bottom of the + // virtualized list, load the next page of older notes. loadOlderNotes + // self-guards against concurrent calls and end-of-feed. + const virtualItems = virtualizer.getVirtualItems(); + useEffect(() => { + if (tab !== "global") return; + const last = virtualItems[virtualItems.length - 1]; + if (last && last.index >= filteredNotes.length - 8) { + loadOlderNotes(); + } + }, [virtualItems, filteredNotes.length, tab, loadOlderNotes]); + return (
{/* Header */} @@ -307,7 +321,7 @@ export function Feed() { {/* Virtualized list — only the visible window of cards stays in the DOM */} {filteredNotes.length > 0 && (
- {virtualizer.getVirtualItems().map((vi) => { + {virtualItems.map((vi) => { const event = filteredNotes[vi.index]; return (
)} + + {tab === "global" && loadingOlder && ( +
Loading older notes…
+ )}
); diff --git a/src/lib/nostr/notes.ts b/src/lib/nostr/notes.ts index bdb0062..1723f1a 100644 --- a/src/lib/nostr/notes.ts +++ b/src/lib/nostr/notes.ts @@ -2,11 +2,17 @@ import { NDKEvent, NDKFilter, NDKKind, NDKRelaySet, nip19 } from "@nostr-dev-kit import { getNDK, getStoredRelayUrls, fetchWithTimeout, withTimeout, FEED_TIMEOUT, THREAD_TIMEOUT, SINGLE_TIMEOUT } from "./core"; import { fetchUserRelayList } from "./relays"; -export async function fetchGlobalFeed(limit: number = 50): Promise { +export async function fetchGlobalFeed(limit: number = 50, until?: number): Promise { const instance = getNDK(); - // Ask for notes from the last 2 hours to ensure freshness - const since = Math.floor(Date.now() / 1000) - 2 * 3600; - const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], limit, since }; + const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], limit }; + if (until !== undefined) { + // Older-notes pagination (infinite scroll): fetch events before `until`, + // with no `since` bound so we can page arbitrarily far back. + filter.until = until; + } else { + // Default: notes from the last 2 hours to ensure freshness. + filter.since = Math.floor(Date.now() / 1000) - 2 * 3600; + } const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 762122d..9c8d882 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -12,7 +12,9 @@ import { debug } from "../lib/debug"; const TRENDING_CACHE_KEY = "wrystr_trending_cache"; const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes -const MAX_FEED_SIZE = 200; +// Virtualization bounds the DOM-node / decoded-bitmap count; this cap just +// bounds the in-memory `notes` array as hygiene (not a hard memory limit). +const MAX_FEED_SIZE = 1000; // Live subscription handle — persists across store calls let liveSub: NDKSubscription | null = null; @@ -34,6 +36,8 @@ interface FeedState { notes: NDKEvent[]; pendingNotes: NDKEvent[]; loading: boolean; + loadingOlder: boolean; + feedReachedEnd: boolean; connected: boolean; error: string | null; focusedNoteIndex: number; @@ -43,6 +47,7 @@ interface FeedState { connect: () => Promise; loadCachedFeed: () => Promise; loadFeed: () => Promise; + loadOlderNotes: () => Promise; startLiveFeed: () => void; flushPendingNotes: () => void; loadTrendingFeed: (force?: boolean) => Promise; @@ -53,6 +58,8 @@ export const useFeedStore = create((set, get) => ({ notes: [], pendingNotes: [], loading: false, + loadingOlder: false, + feedReachedEnd: false, connected: false, error: null, focusedNoteIndex: -1, @@ -185,7 +192,7 @@ export const useFeedStore = create((set, get) => ({ */ loadFeed: async () => { if (get().loading) return; - set({ loading: true, error: null }); + set({ loading: true, error: null, feedReachedEnd: false }); try { await ensureConnected(); const fresh = await diagWrapFetch("global_fetch", () => fetchGlobalFeed(100)); @@ -211,6 +218,41 @@ export const useFeedStore = create((set, get) => ({ } }, + /** + * Load a page of older notes for infinite scroll. Fetches notes created + * before the oldest one currently loaded, dedups, merges, caps at + * MAX_FEED_SIZE. Self-guards against concurrent calls and end-of-feed. + */ + loadOlderNotes: async () => { + const { notes, loadingOlder, feedReachedEnd } = get(); + if (loadingOlder || feedReachedEnd || notes.length === 0) return; + if (notes.length >= MAX_FEED_SIZE) { + set({ feedReachedEnd: true }); + return; + } + set({ loadingOlder: true }); + try { + const oldest = notes[notes.length - 1].created_at ?? Math.floor(Date.now() / 1000); + const older = await diagWrapFetch("global_older", () => fetchGlobalFeed(50, oldest)); + // Re-read notes after the await — a refresh may have run concurrently. + const current = get().notes; + const currentIds = new Set(current.map((n) => n.id)); + const newOlder = older.filter((e) => !currentIds.has(e.id)); + if (newOlder.length === 0) { + // Relay returned nothing new — end of available history. + set({ loadingOlder: false, feedReachedEnd: true }); + return; + } + const merged = [...current, ...newOlder] + .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) + .slice(0, MAX_FEED_SIZE); + set({ notes: merged, loadingOlder: false }); + } catch (err) { + debug.warn("[Vega] Failed to load older notes:", err); + set({ loadingOlder: false }); + } + }, + /** * Start a persistent live subscription for new notes. * New events stream in and are prepended to the feed in real time.