diff --git a/src/components/article/ArticleFeed.tsx b/src/components/article/ArticleFeed.tsx index b7fa1ae..eaa5aae 100644 --- a/src/components/article/ArticleFeed.tsx +++ b/src/components/article/ArticleFeed.tsx @@ -14,14 +14,20 @@ export function ArticleFeed() { const [articles, setArticles] = useState([]); const [loading, setLoading] = useState(true); + // Track follows length to avoid re-fetching latest when follows change + const followsKey = tab === "following" ? follows.join(",") : "latest"; + useEffect(() => { + if (tab === "following" && follows.length === 0) return; + let cancelled = false; setLoading(true); const authors = tab === "following" ? follows : undefined; fetchArticleFeed(40, authors) - .then(setArticles) - .catch(() => setArticles([])) - .finally(() => setLoading(false)); - }, [tab, follows]); + .then((result) => { if (!cancelled) setArticles(result); }) + .catch(() => { if (!cancelled) setArticles([]); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, [followsKey]); return (
diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index 2c969df..6b1f2a4 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -6,7 +6,7 @@ import { useUserStore } from "../../stores/user"; import { useMuteStore } from "../../stores/mute"; import { useUIStore } from "../../stores/ui"; 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 { NoteContent } from "./NoteContent"; import { NoteActions, LoggedOutStats } from "./NoteActions"; @@ -132,6 +132,7 @@ export function NoteCard({ event, focused, onReplyInThread }: NoteCardProps) { return; } } + await ensureConnected(); const parent = await fetchNoteById(parentEventId); if (parent) openThread(parent); }} diff --git a/src/components/zap/ZapHistoryView.tsx b/src/components/zap/ZapHistoryView.tsx index dc5aa8d..44cb07e 100644 --- a/src/components/zap/ZapHistoryView.tsx +++ b/src/components/zap/ZapHistoryView.tsx @@ -107,11 +107,11 @@ export function ZapHistoryView() { const [tab, setTab] = useState("received"); const [received, setReceived] = useState([]); const [sent, setSent] = useState([]); - const [loading, setLoading] = useState(false); + const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { - if (!pubkey) return; + if (!pubkey) { setLoading(false); return; } setLoading(true); setError(null); Promise.all([ diff --git a/src/lib/nostr/articles.ts b/src/lib/nostr/articles.ts index 0f57375..b5042e4 100644 --- a/src/lib/nostr/articles.ts +++ b/src/lib/nostr/articles.ts @@ -1,5 +1,5 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage, nip19 } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind, nip19 } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, FEED_TIMEOUT, SINGLE_TIMEOUT } from "./core"; export async function publishArticle(opts: { title: string; @@ -45,9 +45,7 @@ export async function fetchArticle(naddr: string): Promise { "#d": [identifier], limit: 1, }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); return Array.from(events)[0] ?? null; } catch { return null; @@ -57,9 +55,7 @@ export async function fetchArticle(naddr: string): Promise { export async function fetchAuthorArticles(pubkey: string, limit = 20): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [NDKKind.Article], authors: [pubkey], limit }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); 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 filter: NDKFilter = { kinds: [NDKKind.Article], limit }; if (authors && authors.length > 0) filter.authors = authors; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); 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 (b.created_at ?? 0) - (a.created_at ?? 0)); } @@ -99,8 +91,6 @@ export async function fetchByAddr(addr: string): Promise { "#d": [dTag], limit: 1, }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); return Array.from(events)[0] ?? null; } diff --git a/src/lib/nostr/bookmarks.ts b/src/lib/nostr/bookmarks.ts index 4a79dfe..2a3f96a 100644 --- a/src/lib/nostr/bookmarks.ts +++ b/src/lib/nostr/bookmarks.ts @@ -1,12 +1,10 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core"; export async function fetchBookmarkList(pubkey: string): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [10003 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 []; 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]); @@ -25,9 +23,7 @@ export async function publishBookmarkList(eventIds: string[]): Promise { export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> { const instance = getNDK(); const filter: NDKFilter = { kinds: [10003 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 { eventIds: [], articleAddrs: [] }; 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]); diff --git a/src/lib/nostr/core.ts b/src/lib/nostr/core.ts index 18ff51f..2775ba8 100644 --- a/src/lib/nostr/core.ts +++ b/src/lib/nostr/core.ts @@ -26,9 +26,13 @@ export async function fetchWithTimeout( timeoutMs: number, relaySet?: NDKRelaySet, ): Promise> { + const opts = { + cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, + groupable: false, // Prevent NDK from batching/reusing subscriptions + }; const promise = relaySet - ? instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }, relaySet) - : instance.fetchEvents(filter, { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY }); + ? instance.fetchEvents(filter, opts, relaySet) + : instance.fetchEvents(filter, opts); return withTimeout(promise, timeoutMs, EMPTY_SET); } diff --git a/src/lib/nostr/dms.ts b/src/lib/nostr/dms.ts index ac00862..dab3148 100644 --- a/src/lib/nostr/dms.ts +++ b/src/lib/nostr/dms.ts @@ -1,5 +1,5 @@ -import { NDKEvent, NDKKind, NDKSubscriptionCacheUsage, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKKind, giftWrap, giftUnwrap } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, withTimeout, FEED_TIMEOUT } from "./core"; async function unwrapGiftWraps(events: NDKEvent[]): Promise { const instance = getNDK(); @@ -21,21 +21,16 @@ async function unwrapGiftWraps(events: NDKEvent[]): Promise { export async function fetchDMConversations(myPubkey: string): Promise { const instance = getNDK(); - // Fetch NIP-04 (legacy) and NIP-17 (gift-wrap) in parallel - const [nip04Received, nip04Sent, giftWraps] = await Promise.all([ - instance.fetchEvents( - { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - instance.fetchEvents( - { kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - instance.fetchEvents( - { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 500 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - ]); + // Fetch NIP-04 (legacy) and NIP-17 (gift-wrap) in parallel with timeouts + const [nip04Received, nip04Sent, giftWraps] = await withTimeout( + Promise.all([ + fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], limit: 500 }, FEED_TIMEOUT), + fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], authors: [myPubkey], limit: 500 }, FEED_TIMEOUT), + fetchWithTimeout(instance, { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 500 }, FEED_TIMEOUT), + ]), + FEED_TIMEOUT + 2000, + [new Set(), new Set(), new Set()], + ); const nip17Rumors = await unwrapGiftWraps(Array.from(giftWraps)); @@ -47,21 +42,16 @@ export async function fetchDMConversations(myPubkey: string): Promise { const instance = getNDK(); - // Fetch NIP-04 and NIP-17 in parallel - const [fromThem, fromMe, giftWraps] = await Promise.all([ - instance.fetchEvents( - { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - instance.fetchEvents( - { kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - instance.fetchEvents( - { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 200 }, - { cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY } - ), - ]); + // Fetch NIP-04 and NIP-17 in parallel with timeouts + const [fromThem, fromMe, giftWraps] = await withTimeout( + Promise.all([ + fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [myPubkey], authors: [theirPubkey], limit: 200 }, FEED_TIMEOUT), + fetchWithTimeout(instance, { kinds: [NDKKind.EncryptedDirectMessage], "#p": [theirPubkey], authors: [myPubkey], limit: 200 }, FEED_TIMEOUT), + fetchWithTimeout(instance, { kinds: [NDKKind.GiftWrap], "#p": [myPubkey], limit: 200 }, FEED_TIMEOUT), + ]), + FEED_TIMEOUT + 2000, + [new Set(), new Set(), new Set()], + ); // Unwrap NIP-17 and filter to only messages from/to this partner const allRumors = await unwrapGiftWraps(Array.from(giftWraps)); diff --git a/src/lib/nostr/engagement.ts b/src/lib/nostr/engagement.ts index 261c4f8..ee44dd7 100644 --- a/src/lib/nostr/engagement.ts +++ b/src/lib/nostr/engagement.ts @@ -1,5 +1,5 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, withTimeout, FEED_TIMEOUT, SINGLE_TIMEOUT } from "./core"; export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise { const instance = getNDK(); @@ -17,34 +17,22 @@ export async function publishReaction(eventId: string, eventPubkey: string, reac export async function fetchReactionCount(eventId: string): Promise { const instance = getNDK(); - const filter: NDKFilter = { - kinds: [NDKKind.Reaction], - "#e": [eventId], - }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const filter: NDKFilter = { kinds: [NDKKind.Reaction], "#e": [eventId] }; + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); return events.size; } export async function fetchReplyCount(eventId: string): Promise { const instance = getNDK(); - const filter: NDKFilter = { - kinds: [NDKKind.Text], - "#e": [eventId], - }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const filter: NDKFilter = { kinds: [NDKKind.Text], "#e": [eventId] }; + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); return events.size; } export async function fetchZapCount(eventId: string): Promise<{ count: number; totalSats: number }> { const instance = getNDK(); const filter: NDKFilter = { kinds: [NDKKind.Zap], "#e": [eventId] }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); let totalSats = 0; for (const event of events) { const desc = event.tags.find((t) => t[0] === "description")?.[1]; @@ -71,20 +59,15 @@ export async function fetchBatchEngagement(eventIds: string[]): Promise(), new Set(), new Set()], + ); for (const event of reactions) { const eTag = event.tags.find((t) => t[0] === "e")?.[1]; @@ -117,18 +100,15 @@ export async function fetchBatchEngagement(eventIds: string[]): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [NDKKind.Zap], "#p": [pubkey], limit }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + // Zap queries can be slow — give relays more time + const events = await fetchWithTimeout(instance, filter, 12000); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } export async function fetchZapsSent(pubkey: string, limit = 50): Promise { 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 events = await instance.fetchEvents(filter, { - 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)); } diff --git a/src/lib/nostr/muting.ts b/src/lib/nostr/muting.ts index f82535d..26a945a 100644 --- a/src/lib/nostr/muting.ts +++ b/src/lib/nostr/muting.ts @@ -1,12 +1,10 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core"; export async function fetchMuteList(pubkey: string): Promise { const instance = getNDK(); const filter: NDKFilter = { kinds: [10000 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 []; 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]); diff --git a/src/lib/nostr/notes.ts b/src/lib/nostr/notes.ts index 034422f..4981a5d 100644 --- a/src/lib/nostr/notes.ts +++ b/src/lib/nostr/notes.ts @@ -4,7 +4,9 @@ import { fetchUserRelayList } from "./relays"; export async function fetchGlobalFeed(limit: number = 50): Promise { 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); 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 { export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise { if (pubkeys.length === 0) return []; 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); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } diff --git a/src/lib/nostr/relays.ts b/src/lib/nostr/relays.ts index ed2921c..0084446 100644 --- a/src/lib/nostr/relays.ts +++ b/src/lib/nostr/relays.ts @@ -1,12 +1,12 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core"; export interface UserRelayList { read: string[]; write: string[]; } export async function fetchUserRelayList(pubkey: string): Promise { const instance = getNDK(); 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: [] }; const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0]; const read: string[] = [], write: string[] = []; diff --git a/src/lib/nostr/search.ts b/src/lib/nostr/search.ts index 0ea1fac..97d19d6 100644 --- a/src/lib/nostr/search.ts +++ b/src/lib/nostr/search.ts @@ -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 { getNDK } from "./core"; +import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core"; export async function searchNotes(query: string, limit = 50): Promise { const instance = getNDK(); @@ -8,9 +8,7 @@ export async function searchNotes(query: string, limit = 50): Promise (b.created_at ?? 0) - (a.created_at ?? 0)); } @@ -21,9 +19,7 @@ export async function searchUsers(query: string, limit = 20): Promise> => { - return Promise.race([ - instance.fetchEvents(filter, opts), - new Promise>((resolve) => setTimeout(() => resolve(new Set()), timeoutMs)), - ]); - }; - const noteFilter = noteKinds.length > 0 ? buildFilter(noteKinds) : null; const articleFilter = articleKinds.length > 0 ? buildFilter(articleKinds) : null; const shouldSearchUsers = (!hasKindFilter || parsed.kinds.includes(0)) && hasSearch && !hasHashtags; const [noteEvents, articleEvents, userEvents] = await Promise.all([ - noteFilter ? fetchWithTimeout(noteFilter) : Promise.resolve(new Set()), - articleFilter ? fetchWithTimeout(articleFilter) : Promise.resolve(new Set()), - shouldSearchUsers ? fetchWithTimeout({ kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }) : Promise.resolve(new Set()), + noteFilter ? fetchWithTimeout(instance, noteFilter, FEED_TIMEOUT) : Promise.resolve(new Set()), + articleFilter ? fetchWithTimeout(instance, articleFilter, FEED_TIMEOUT) : Promise.resolve(new Set()), + shouldSearchUsers ? fetchWithTimeout(instance, { kinds: [NDKKind.Metadata], search: searchText, limit: 20 } as NDKFilter & { search: string }, FEED_TIMEOUT) : Promise.resolve(new Set()), ]); let notes = Array.from(noteEvents); diff --git a/src/lib/nostr/social.ts b/src/lib/nostr/social.ts index 92fda3c..cc9fdd0 100644 --- a/src/lib/nostr/social.ts +++ b/src/lib/nostr/social.ts @@ -1,5 +1,5 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core"; export async function publishProfile(fields: { name?: string; @@ -47,9 +47,7 @@ export async function fetchFollowSuggestions(myFollows: string[]): Promise<{ pub for (let i = 0; i < myFollows.length; i += batchSize) { const batch = myFollows.slice(i, i + batchSize); const filter: NDKFilter = { kinds: [3 as NDKKind], authors: batch, limit: batch.length }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); 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 { const instance = getNDK(); - const events = await instance.fetchEvents( + const events = await fetchWithTimeout( + instance, { 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)); } @@ -91,8 +90,6 @@ export async function fetchNewFollowers(pubkey: string, since: number, limit = 2 since, limit, }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + 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/lib/nostr/trending.ts b/src/lib/nostr/trending.ts index 727c3eb..e523d3a 100644 --- a/src/lib/nostr/trending.ts +++ b/src/lib/nostr/trending.ts @@ -1,5 +1,5 @@ -import { NDKEvent, NDKFilter, NDKKind, NDKSubscriptionCacheUsage } from "@nostr-dev-kit/ndk"; -import { getNDK } from "./core"; +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, FEED_TIMEOUT } from "./core"; export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Promise { const instance = getNDK(); @@ -9,9 +9,7 @@ export async function fetchTrendingCandidates(limit = 200, sinceHours = 24): Pro since, limit, }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); 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, limit: 500, }; - const events = await instance.fetchEvents(filter, { - cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY, - }); + const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); const counts = new Map(); for (const event of events) { diff --git a/src/stores/feed.ts b/src/stores/feed.ts index 7d9f2ca..492e286 100644 --- a/src/stores/feed.ts +++ b/src/stores/feed.ts @@ -1,12 +1,16 @@ 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 { dbLoadFeed, dbSaveNotes } from "../lib/db"; import { diagWrapFetch, logDiag, startRelaySnapshots, getRelayStates } from "../lib/feedDiagnostics"; const TRENDING_CACHE_KEY = "wrystr_trending_cache"; 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 | null = null; interface FeedState { notes: NDKEvent[]; @@ -19,6 +23,7 @@ interface FeedState { connect: () => Promise; loadCachedFeed: () => Promise; loadFeed: () => Promise; + startLiveFeed: () => void; loadTrendingFeed: (force?: boolean) => Promise; setFocusedNoteIndex: (n: number) => void; } @@ -73,7 +78,11 @@ export const useFeedStore = create((set, get) => ({ resetNDK().then(() => { if (getNDK().pool?.relays) { 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(() => {}); } else { @@ -90,7 +99,7 @@ export const useFeedStore = create((set, get) => ({ loadCachedFeed: async () => { try { - const rawNotes = await dbLoadFeed(200); + const rawNotes = await dbLoadFeed(MAX_FEED_SIZE); if (rawNotes.length === 0) return; const ndk = getNDK(); const events = rawNotes.map((raw) => new NDKEvent(ndk, JSON.parse(raw))); @@ -100,6 +109,9 @@ export const useFeedStore = create((set, get) => ({ } }, + /** + * One-shot feed fetch — loads initial batch, then starts live subscription. + */ loadFeed: async () => { if (get().loading) return; set({ loading: true, error: null }); @@ -107,23 +119,78 @@ export const useFeedStore = create((set, get) => ({ await ensureConnected(); const fresh = await diagWrapFetch("global_fetch", () => fetchGlobalFeed(80)); - // Merge with currently displayed notes so cached notes aren't lost - // if the relay returns fewer results than the cache had. + // Merge with currently displayed notes const freshIds = new Set(fresh.map((n) => n.id)); const kept = get().notes.filter((n) => !freshIds.has(n.id)); const merged = [...fresh, ...kept] .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 }); // Persist fresh notes to SQLite (fire-and-forget) dbSaveNotes(fresh.map((e) => JSON.stringify(e.rawEvent()))); + + // Start live subscription after initial load + get().startLiveFeed(); } catch (err) { 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) => { if (get().trendingLoading) return;