Add live feed subscription, timeouts on all relay fetches

Global feed now uses a persistent live subscription (closeOnEose: false)
so new notes stream in real-time instead of requiring manual refresh.
Inspired by Wisp's streaming architecture.

Every fetchEvents call across the entire codebase now uses
fetchWithTimeout with groupable: false — prevents NDK from
batching/reusing stale subscriptions. This fixes Articles, DMs,
Notifications, Zaps, and Trending hanging indefinitely.

Also adds since filters on global (2h) and follow (24h) feeds
to ensure relay freshness, and fixes ArticleFeed re-fetch bug
where follows changes wiped latest tab results.
This commit is contained in:
Jure
2026-03-22 11:35:28 +01:00
parent 2e03c6ce11
commit b3e7ff7029
15 changed files with 176 additions and 162 deletions

View File

@@ -14,14 +14,20 @@ export function ArticleFeed() {
const [articles, setArticles] = useState<NDKEvent[]>([]); const [articles, setArticles] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Track follows length to avoid re-fetching latest when follows change
const followsKey = tab === "following" ? follows.join(",") : "latest";
useEffect(() => { useEffect(() => {
if (tab === "following" && follows.length === 0) return;
let cancelled = false;
setLoading(true); setLoading(true);
const authors = tab === "following" ? follows : undefined; const authors = tab === "following" ? follows : undefined;
fetchArticleFeed(40, authors) fetchArticleFeed(40, authors)
.then(setArticles) .then((result) => { if (!cancelled) setArticles(result); })
.catch(() => setArticles([])) .catch(() => { if (!cancelled) setArticles([]); })
.finally(() => setLoading(false)); .finally(() => { if (!cancelled) setLoading(false); });
}, [tab, follows]); return () => { cancelled = true; };
}, [followsKey]);
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">

View File

@@ -6,7 +6,7 @@ import { useUserStore } from "../../stores/user";
import { useMuteStore } from "../../stores/mute"; import { useMuteStore } from "../../stores/mute";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { timeAgo, shortenPubkey } from "../../lib/utils"; import { timeAgo, shortenPubkey } from "../../lib/utils";
import { getNDK, fetchNoteById } from "../../lib/nostr"; import { getNDK, fetchNoteById, ensureConnected } from "../../lib/nostr";
import { getParentEventId } from "../../lib/threadTree"; import { getParentEventId } from "../../lib/threadTree";
import { NoteContent } from "./NoteContent"; import { NoteContent } from "./NoteContent";
import { NoteActions, LoggedOutStats } from "./NoteActions"; import { NoteActions, LoggedOutStats } from "./NoteActions";
@@ -132,6 +132,7 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) {
return; return;
} }
} }
await ensureConnected();
const parent = await fetchNoteById(parentEventId); const parent = await fetchNoteById(parentEventId);
if (parent) openThread(parent); if (parent) openThread(parent);
}} }}

View File

@@ -107,11 +107,11 @@ export function ZapHistoryView() {
const [tab, setTab] = useState<Tab>("received"); const [tab, setTab] = useState<Tab>("received");
const [received, setReceived] = useState<NDKEvent[]>([]); const [received, setReceived] = useState<NDKEvent[]>([]);
const [sent, setSent] = useState<NDKEvent[]>([]); const [sent, setSent] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!pubkey) return; if (!pubkey) { setLoading(false); return; }
setLoading(true); setLoading(true);
setError(null); setError(null);
Promise.all([ Promise.all([

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind, nip19 } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, FEED_TIMEOUT, SINGLE_TIMEOUT } from "./core";
export async function publishArticle(opts: { export async function publishArticle(opts: {
title: string; title: string;
@@ -45,9 +45,7 @@ export async function fetchArticle(naddr: string): Promise<NDKEvent | null> {
"#d": [identifier], "#d": [identifier],
limit: 1, limit: 1,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events)[0] ?? null; return Array.from(events)[0] ?? null;
} catch { } catch {
return null; return null;
@@ -57,9 +55,7 @@ export async function fetchArticle(naddr: string): Promise<NDKEvent | null> {
export async function fetchAuthorArticles(pubkey: string, limit = 20): Promise<NDKEvent[]> { export async function fetchAuthorArticles(pubkey: string, limit = 20): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Article], authors: [pubkey], limit }; const filter: NDKFilter = { kinds: [NDKKind.Article], authors: [pubkey], limit };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -67,9 +63,7 @@ export async function fetchArticleFeed(limit = 40, authors?: string[]): Promise<
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Article], limit }; const filter: NDKFilter = { kinds: [NDKKind.Article], limit };
if (authors && authors.length > 0) filter.authors = authors; if (authors && authors.length > 0) filter.authors = authors;
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -79,9 +73,7 @@ export async function searchArticles(query: string, limit = 30): Promise<NDKEven
const filter: NDKFilter & { search?: string } = isHashtag const filter: NDKFilter & { search?: string } = isHashtag
? { kinds: [NDKKind.Article], "#t": [query.slice(1).toLowerCase()], limit } ? { kinds: [NDKKind.Article], "#t": [query.slice(1).toLowerCase()], limit }
: { kinds: [NDKKind.Article], search: query, limit }; : { kinds: [NDKKind.Article], search: query, limit };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -99,8 +91,6 @@ export async function fetchByAddr(addr: string): Promise<NDKEvent | null> {
"#d": [dTag], "#d": [dTag],
limit: 1, limit: 1,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events)[0] ?? null; return Array.from(events)[0] ?? null;
} }

View File

@@ -1,12 +1,10 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core";
export async function fetchBookmarkList(pubkey: string): Promise<string[]> { export async function fetchBookmarkList(pubkey: string): Promise<string[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return []; if (events.size === 0) return [];
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
return event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]); return event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
@@ -25,9 +23,7 @@ export async function publishBookmarkList(eventIds: string[]): Promise<void> {
export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> { export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return { eventIds: [], articleAddrs: [] }; if (events.size === 0) return { eventIds: [], articleAddrs: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const eventIds = event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]); const eventIds = event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);

View File

@@ -26,9 +26,13 @@ export async function fetchWithTimeout(
timeoutMs: number, timeoutMs: number,
relaySet?: NDKRelaySet, relaySet?: NDKRelaySet,
): Promise<Set<NDKEvent>> { ): Promise<Set<NDKEvent>> {
const opts = {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
groupable: false, // Prevent NDK from batching/reusing subscriptions
};
const promise = relaySet const promise = relaySet
? instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }, relaySet) ? instance.fetchEvents(filter, opts, relaySet)
: instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }); : instance.fetchEvents(filter, opts);
return withTimeout(promise, timeoutMs, EMPTY_SET); return withTimeout(promise, timeoutMs, EMPTY_SET);
} }

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKKind, NDKSubscriptionCacheUsage, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKKind, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, withTimeout, FEED_TIMEOUT } from "./core";
async function unwrapGiftWraps(events: NDKEvent[]): Promise<NDKEvent[]> { async function unwrapGiftWraps(events: NDKEvent[]): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
@@ -21,21 +21,16 @@ async function unwrapGiftWraps(events: NDKEvent[]): Promise<NDKEvent[]> {
export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]> { export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
// Fetch NIP-04 (legacy) and NIP-17 (gift-wrap) in parallel // Fetch NIP-04 (legacy) and NIP-17 (gift-wrap) in parallel with timeouts
const [nip04Received, nip04Sent, giftWraps] = await Promise.all([ const [nip04Received, nip04Sent, giftWraps] = await withTimeout(
instance.fetchEvents( Promise.all([
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 }, fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 }, FEED_TIMEOUT),
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 }, FEED_TIMEOUT),
), fetchWithTimeout(instance, { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 500 }, FEED_TIMEOUT),
instance.fetchEvents( ]),
{ kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 }, FEED_TIMEOUT + 2000,
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } [new Set<NDKEvent>(), new Set<NDKEvent>(), new Set<NDKEvent>()],
), );
instance.fetchEvents(
{ kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 500 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
const nip17Rumors = await unwrapGiftWraps(Array.from(giftWraps)); const nip17Rumors = await unwrapGiftWraps(Array.from(giftWraps));
@@ -47,21 +42,16 @@ export async function fetchDMConversations(myPubkey: string): Promise<NDKEvent[]
export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> { export async function fetchDMThread(myPubkey: string, theirPubkey: string): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
// Fetch NIP-04 and NIP-17 in parallel // Fetch NIP-04 and NIP-17 in parallel with timeouts
const [fromThem, fromMe, giftWraps] = await Promise.all([ const [fromThem, fromMe, giftWraps] = await withTimeout(
instance.fetchEvents( Promise.all([
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 }, fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 }, FEED_TIMEOUT),
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 }, FEED_TIMEOUT),
), fetchWithTimeout(instance, { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 200 }, FEED_TIMEOUT),
instance.fetchEvents( ]),
{ kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 }, FEED_TIMEOUT + 2000,
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } [new Set<NDKEvent>(), new Set<NDKEvent>(), new Set<NDKEvent>()],
), );
instance.fetchEvents(
{ kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 200 },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
// Unwrap NIP-17 and filter to only messages from/to this partner // Unwrap NIP-17 and filter to only messages from/to this partner
const allRumors = await unwrapGiftWraps(Array.from(giftWraps)); const allRumors = await unwrapGiftWraps(Array.from(giftWraps));

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, withTimeout, FEED_TIMEOUT, SINGLE_TIMEOUT } from "./core";
export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> { export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> {
const instance = getNDK(); const instance = getNDK();
@@ -17,34 +17,22 @@ export async function publishReaction(eventId: string, eventPubkey: string, reac
export async function fetchReactionCount(eventId: string): Promise<number> { export async function fetchReactionCount(eventId: string): Promise<number> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { const filter: NDKFilter = { kinds: [NDKKind.Reaction], "#e": [eventId] };
kinds: [NDKKind.Reaction], const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
"#e": [eventId],
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return events.size; return events.size;
} }
export async function fetchReplyCount(eventId: string): Promise<number> { export async function fetchReplyCount(eventId: string): Promise<number> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { const filter: NDKFilter = { kinds: [NDKKind.Text], "#e": [eventId] };
kinds: [NDKKind.Text], const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
"#e": [eventId],
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return events.size; return events.size;
} }
export async function fetchZapCount(eventId: string): Promise<{ count: number; totalSats: number }> { export async function fetchZapCount(eventId: string): Promise<{ count: number; totalSats: number }> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#e": [eventId] }; const filter: NDKFilter = { kinds: [NDKKind.Zap], "#e": [eventId] };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
let totalSats = 0; let totalSats = 0;
for (const event of events) { for (const event of events) {
const desc = event.tags.find((t) => t[0] === "description")?.[1]; const desc = event.tags.find((t) => t[0] === "description")?.[1];
@@ -71,20 +59,15 @@ export async function fetchBatchEngagement(eventIds: string[]): Promise<Map<stri
for (let i = 0; i < eventIds.length; i += chunkSize) { for (let i = 0; i < eventIds.length; i += chunkSize) {
const chunk = eventIds.slice(i, i + chunkSize); const chunk = eventIds.slice(i, i + chunkSize);
const [reactions, replies, zaps] = await Promise.all([ const [reactions, replies, zaps] = await withTimeout(
instance.fetchEvents( Promise.all([
{ kinds: [NDKKind.Reaction], "#e": chunk }, fetchWithTimeout(instance, { kinds: [NDKKind.Reaction], "#e": chunk }, FEED_TIMEOUT),
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } fetchWithTimeout(instance, { kinds: [NDKKind.Text], "#e": chunk }, FEED_TIMEOUT),
), fetchWithTimeout(instance, { kinds: [NDKKind.Zap], "#e": chunk }, FEED_TIMEOUT),
instance.fetchEvents( ]),
{ kinds: [NDKKind.Text], "#e": chunk }, FEED_TIMEOUT + 2000, // outer timeout slightly longer than inner
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } [new Set<NDKEvent>(), new Set<NDKEvent>(), new Set<NDKEvent>()],
), );
instance.fetchEvents(
{ kinds: [NDKKind.Zap], "#e": chunk },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }
),
]);
for (const event of reactions) { for (const event of reactions) {
const eTag = event.tags.find((t) => t[0] === "e")?.[1]; const eTag = event.tags.find((t) => t[0] === "e")?.[1];
@@ -117,18 +100,15 @@ export async function fetchBatchEngagement(eventIds: string[]): Promise<Map<stri
export async function fetchZapsReceived(pubkey: string, limit = 50): Promise<NDKEvent[]> { export async function fetchZapsReceived(pubkey: string, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit }; const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit };
const events = await instance.fetchEvents(filter, { // Zap queries can be slow — give relays more time
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, const events = await fetchWithTimeout(instance, filter, 12000);
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
export async function fetchZapsSent(pubkey: string, limit = 50): Promise<NDKEvent[]> { export async function fetchZapsSent(pubkey: string, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
// Zap receipts (kind 9735) with uppercase P tag = the sender's pubkey // #P (uppercase) is poorly supported; also try finding zap requests we authored
const filter: NDKFilter = { kinds: [NDKKind.Zap], "#P": [pubkey], limit }; const filter: NDKFilter = { kinds: [NDKKind.Zap], "#P": [pubkey], limit };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, 12000);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }

View File

@@ -1,12 +1,10 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core";
export async function fetchMuteList(pubkey: string): Promise<string[]> { export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return []; if (events.size === 0) return [];
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
return event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]); return event.tags.filter((t) => t[0] === "p" && t[1]).map((t) => t[1]);

View File

@@ -4,7 +4,9 @@ import { fetchUserRelayList } from "./relays";
export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> { export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Text], limit }; // 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], limit, since };
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -12,7 +14,8 @@ export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> {
export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise<NDKEvent[]> { export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise<NDKEvent[]> {
if (pubkeys.length === 0) return []; if (pubkeys.length === 0) return [];
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [NDKKind.Text], authors: pubkeys, limit }; const since = Math.floor(Date.now() / 1000) - 24 * 3600; // last 24h for follows
const filter: NDKFilter = { kinds: [NDKKind.Text], authors: pubkeys, limit, since };
const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }

View File

@@ -1,12 +1,12 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core";
export interface UserRelayList { read: string[]; write: string[]; } export interface UserRelayList { read: string[]; write: string[]; }
export async function fetchUserRelayList(pubkey: string): Promise<UserRelayList> { export async function fetchUserRelayList(pubkey: string): Promise<UserRelayList> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10002 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10002 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }); const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT);
if (events.size === 0) return { read: [], write: [] }; if (events.size === 0) return { read: [], write: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const read: string[] = [], write: string[] = []; const read: string[] = [], write: string[] = [];

View File

@@ -1,6 +1,6 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage, NDKUser } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind, NDKUser } from "@nostr-dev-kit/ndk";
import { type ParsedSearch, matchesHasFilter } from "../search"; import { type ParsedSearch, matchesHasFilter } from "../search";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core";
export async function searchNotes(query: string, limit = 50): Promise<NDKEvent[]> { export async function searchNotes(query: string, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
@@ -8,9 +8,7 @@ export async function searchNotes(query: string, limit = 50): Promise<NDKEvent[]
const filter: NDKFilter & { search?: string } = isHashtag const filter: NDKFilter & { search?: string } = isHashtag
? { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit } ? { kinds: [NDKKind.Text], "#t": [query.slice(1).toLowerCase()], limit }
: { kinds: [NDKKind.Text], search: query, limit }; : { kinds: [NDKKind.Text], search: query, limit };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -21,9 +19,7 @@ export async function searchUsers(query: string, limit = 20): Promise<NDKEvent[]
search: query, search: query,
limit, limit,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events); return Array.from(events);
} }
@@ -119,24 +115,14 @@ export async function advancedSearch(parsed: ParsedSearch, limit = 50): Promise<
return filter; return filter;
}; };
const opts = { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY };
// Wrap fetchEvents with a timeout — NDK can hang forever if no relay supports the filter
const fetchWithTimeout = (filter: NDKFilter & { search?: string }, timeoutMs = 8000): Promise<Set<NDKEvent>> => {
return Promise.race([
instance.fetchEvents(filter, opts),
new Promise<Set<NDKEvent>>((resolve) => setTimeout(() => resolve(new Set()), timeoutMs)),
]);
};
const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null; const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null;
const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null; const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null;
const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags; const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags;
const [noteEvents, articleEvents, userEvents] = await Promise.all([ const [noteEvents, articleEvents, userEvents] = await Promise.all([
noteFilter ? fetchWithTimeout(noteFilter) : Promise.resolve(new Set<NDKEvent>()), noteFilter ? fetchWithTimeout(instance, noteFilter, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
articleFilter ? fetchWithTimeout(articleFilter) : Promise.resolve(new Set<NDKEvent>()), articleFilter ? fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
shouldSearchUsers ? fetchWithTimeout({ kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }) : Promise.resolve(new Set<NDKEvent>()), shouldSearchUsers ? fetchWithTimeout(instance, { kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, FEED_TIMEOUT) : Promise.resolve(new Set<NDKEvent>()),
]); ]);
let notes = Array.from(noteEvents); let notes = Array.from(noteEvents);

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core";
export async function publishProfile(fields: { export async function publishProfile(fields: {
name?: string; name?: string;
@@ -47,9 +47,7 @@ export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pub
for (let i = 0; i < myFollows.length; i += batchSize) { for (let i = 0; i < myFollows.length; i += batchSize) {
const batch = myFollows.slice(i, i + batchSize); const batch = myFollows.slice(i, i + batchSize);
const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length }; const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
allContactEvents.push(...Array.from(events)); allContactEvents.push(...Array.from(events));
} }
@@ -76,9 +74,10 @@ export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pub
export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> { export async function fetchMentions(pubkey: string, since: number, limit = 50): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
const events = await instance.fetchEvents( const events = await fetchWithTimeout(
instance,
{ kinds: [NDKKind.Text], "#p": [pubkey], since, limit }, { kinds: [NDKKind.Text], "#p": [pubkey], since, limit },
{ cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } FEED_TIMEOUT,
); );
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -91,8 +90,6 @@ export async function fetchNewFollowers(pubkey: string, since: number, limit = 2
since, since,
limit, limit,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }

View File

@@ -1,5 +1,5 @@
import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk";
import { getNDK } from "./core"; import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core";
export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Promise<NDKEvent[]> { export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Promise<NDKEvent[]> {
const instance = getNDK(); const instance = getNDK();
@@ -9,9 +9,7 @@ export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Pro
since, since,
limit, limit,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
} }
@@ -23,9 +21,7 @@ export async function fetchTrendingHashtags(limit = 15): Promise<{ tag: string;
since, since,
limit: 500, limit: 500,
}; };
const events = await instance.fetchEvents(filter, { const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT);
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const event of events) { for (const event of events) {

View File

@@ -1,12 +1,16 @@
import { create } from "zustand"; import { create } from "zustand";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent, NDKFilter, NDKKind, NDKSubscription, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk";
import { connectToRelays, ensureConnected, resetNDK, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr"; import { connectToRelays, ensureConnected, resetNDK, fetchGlobalFeed, fetchBatchEngagement, fetchTrendingCandidates, getNDK } from "../lib/nostr";
import { dbLoadFeed, dbSaveNotes } from "../lib/db"; import { dbLoadFeed, dbSaveNotes } from "../lib/db";
import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics"; import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics";
const TRENDING_CACHE_KEY = "wrystr_trending_cache"; const TRENDING_CACHE_KEY = "wrystr_trending_cache";
const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes const TRENDING_TTL = 10 * 60 * 1000; // 10 minutes
const MAX_FEED_SIZE = 200;
// Live subscription handle — persists across store calls
let liveSub: NDKSubscription | null = null;
let saveTimer: ReturnType<typeof setTimeout> | null = null;
interface FeedState { interface FeedState {
notes: NDKEvent[]; notes: NDKEvent[];
@@ -19,6 +23,7 @@ interface FeedState {
connect: () => Promise<void>; connect: () => Promise<void>;
loadCachedFeed: () => Promise<void>; loadCachedFeed: () => Promise<void>;
loadFeed: () => Promise<void>; loadFeed: () => Promise<void>;
startLiveFeed: () => void;
loadTrendingFeed: (force?: boolean) => Promise<void>; loadTrendingFeed: (force?: boolean) => Promise<void>;
setFocusedNoteIndex: (n: number) => void; setFocusedNoteIndex: (n: number) => void;
} }
@@ -73,7 +78,11 @@ export const useFeedStore = create<FeedState>((set, get) => ({
resetNDK().then(() => { resetNDK().then(() => {
if (getNDK().pool?.relays) { if (getNDK().pool?.relays) {
const after = Array.from(getNDK().pool.relays.values()); const after = Array.from(getNDK().pool.relays.values());
if (after.some((r) => r.connected)) set({ connected: true }); if (after.some((r) => r.connected)) {
set({ connected: true });
// Restart live sub after NDK reset
get().startLiveFeed();
}
} }
}).catch(() => {}); }).catch(() => {});
} else { } else {
@@ -90,7 +99,7 @@ export const useFeedStore = create<FeedState>((set, get) => ({
loadCachedFeed: async () => { loadCachedFeed: async () => {
try { try {
const rawNotes = await dbLoadFeed(200); const rawNotes = await dbLoadFeed(MAX_FEED_SIZE);
if (rawNotes.length === 0) return; if (rawNotes.length === 0) return;
const ndk = getNDK(); const ndk = getNDK();
const events = rawNotes.map((raw) => new NDKEvent(ndk, JSON.parse(raw))); const events = rawNotes.map((raw) => new NDKEvent(ndk, JSON.parse(raw)));
@@ -100,6 +109,9 @@ export const useFeedStore = create<FeedState>((set, get) => ({
} }
}, },
/**
* One-shot feed fetch — loads initial batch, then starts live subscription.
*/
loadFeed: async () => { loadFeed: async () => {
if (get().loading) return; if (get().loading) return;
set({ loading: true, error: null }); set({ loading: true, error: null });
@@ -107,23 +119,78 @@ export const useFeedStore = create<FeedState>((set, get) => ({
await ensureConnected(); await ensureConnected();
const fresh = await diagWrapFetch("global_fetch", () => fetchGlobalFeed(80)); const fresh = await diagWrapFetch("global_fetch", () => fetchGlobalFeed(80));
// Merge with currently displayed notes so cached notes aren't lost // Merge with currently displayed notes
// if the relay returns fewer results than the cache had.
const freshIds = new Set(fresh.map((n) => n.id)); const freshIds = new Set(fresh.map((n) => n.id));
const kept = get().notes.filter((n) => !freshIds.has(n.id)); const kept = get().notes.filter((n) => !freshIds.has(n.id));
const merged = [...fresh, ...kept] const merged = [...fresh, ...kept]
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.slice(0, 200); .slice(0, MAX_FEED_SIZE);
set({ notes: merged, loading: false, focusedNoteIndex: -1 }); set({ notes: merged, loading: false, focusedNoteIndex: -1 });
// Persist fresh notes to SQLite (fire-and-forget) // Persist fresh notes to SQLite (fire-and-forget)
dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent()))); dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent())));
// Start live subscription after initial load
get().startLiveFeed();
} catch (err) { } catch (err) {
set({ error: `Feed failed: ${err}`, loading: false }); set({ error: `Feed failed: ${err}`, loading: false });
} }
}, },
/**
* Start a persistent live subscription for new notes.
* New events stream in and are prepended to the feed in real time.
*/
startLiveFeed: () => {
// Close existing subscription if any
if (liveSub) {
try { liveSub.stop(); } catch { /* ignore */ }
liveSub = null;
}
const ndk = getNDK();
const since = Math.floor(Date.now() / 1000);
const filter: NDKFilter = { kinds: [NDKKind.Text], since, limit: 20 };
const sub = ndk.subscribe(filter, {
closeOnEose: false, // Keep subscription open — this is the key difference
groupable: false,
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
sub.on("event", (event: NDKEvent) => {
const current = get().notes;
// Deduplicate
if (current.some((n) => n.id === event.id)) return;
const updated = [event, ...current]
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
.slice(0, MAX_FEED_SIZE);
set({ notes: updated });
// Debounced save to SQLite — batch saves every 5s
if (!saveTimer) {
saveTimer = setTimeout(() => {
saveTimer = null;
const toSave = get().notes.slice(0, 20);
dbSaveNotes(toSave.map((e) => JSON.stringify(e.rawEvent())));
}, 5000);
}
});
sub.on("eose", () => {
logDiag({
ts: new Date().toISOString(),
action: "live_feed_eose",
details: "Live subscription received EOSE — now streaming new events",
});
});
liveSub = sub;
console.log("[Wrystr] Live feed subscription started");
},
loadTrendingFeed: async (force?: boolean) => { loadTrendingFeed: async (force?: boolean) => {
if (get().trendingLoading) return; if (get().trendingLoading) return;