mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 06:01:57 -07:00
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:
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user