Feed virtualization Stage 2: infinite scroll

Adds older-notes pagination to the Global feed. fetchGlobalFeed gains an
optional until param (fetch events before a timestamp, no since bound).
The feed store's new loadOlderNotes action fetches 50 older notes when
triggered, dedups, merges, re-sorts. MAX_FEED_SIZE raised 200 to 1000 —
virtualization bounds the DOM-node/bitmap count, so this just caps the JS
notes array as hygiene.

Feed.tsx auto-triggers loadOlderNotes when the user scrolls within ~8 rows
of the bottom; a 'Loading older notes' row shows during the fetch. Global
tab only (Following uses a separate path, Trending is a ranked snapshot).

Pending: sandboxed memory verification.
This commit is contained in:
Jure
2026-05-17 20:26:15 +02:00
parent 73c1bd1ac9
commit 7678ad2f1f
3 changed files with 73 additions and 7 deletions
+19 -1
View File
@@ -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 (
<div className="h-full flex flex-col">
{/* Header */}
@@ -307,7 +321,7 @@ export function Feed() {
{/* Virtualized list — only the visible window of cards stays in the DOM */}
{filteredNotes.length > 0 && (
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}>
{virtualizer.getVirtualItems().map((vi) => {
{virtualItems.map((vi) => {
const event = filteredNotes[vi.index];
return (
<div
@@ -326,6 +340,10 @@ export function Feed() {
})}
</div>
)}
{tab === "global" && loadingOlder && (
<div className="py-4 text-center text-text-dim text-[12px]">Loading older notes</div>
)}
</div>
</div>
);
+10 -4
View File
@@ -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<NDKEvent[]> {
export async function fetchGlobalFeed(limit: number = 50, until?: number): Promise<NDKEvent[]> {
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));
}
+44 -2
View File
@@ -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<void>;
loadCachedFeed: () => Promise<void>;
loadFeed: () => Promise<void>;
loadOlderNotes: () => Promise<void>;
startLiveFeed: () => void;
flushPendingNotes: () => void;
loadTrendingFeed: (force?: boolean) => Promise<void>;
@@ -53,6 +58,8 @@ export const useFeedStore = create<FeedState>((set, get) => ({
notes: [],
pendingNotes: [],
loading: false,
loadingOlder: false,
feedReachedEnd: false,
connected: false,
error: null,
focusedNoteIndex: -1,
@@ -185,7 +192,7 @@ export const useFeedStore = create<FeedState>((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<FeedState>((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.