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.