From 3149e5b824c62bd87bf20793ea951fcd80dcc6a1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:43:16 +0200 Subject: [PATCH 01/22] feat(services): add centralized writingsController for kind 30023 --- src/components/Me.tsx | 44 ++++- src/services/writingsController.ts | 250 +++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 3 deletions(-) create mode 100644 src/services/writingsController.ts diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 70ff766b..6220f1d2 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -11,6 +11,7 @@ import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' import { highlightsController } from '../services/highlightsController' +import { writingsController } from '../services/writingsController' import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchLinks } from '../services/linksService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' @@ -81,6 +82,10 @@ const Me: React.FC = ({ const [myHighlights, setMyHighlights] = useState([]) const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + // Get myWritings directly from controller + const [myWritings, setMyWritings] = useState([]) + const [myWritingsLoading, setMyWritingsLoading] = useState(false) + // Load cached data from event store for OTHER profiles (not own) const cachedHighlights = useStoreTimeline( eventStore, @@ -138,6 +143,20 @@ const Me: React.FC = ({ } }, []) + // Subscribe to writings controller + useEffect(() => { + // Get initial state immediately + setMyWritings(writingsController.getWritings()) + + // Subscribe to updates + const unsubWritings = writingsController.onWritings(setMyWritings) + const unsubLoading = writingsController.onLoading(setMyWritingsLoading) + return () => { + unsubWritings() + unsubLoading() + } + }, []) + // Update local state when prop changes useEffect(() => { if (propActiveTab) { @@ -204,8 +223,20 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) - // Seed with cached writings first - if (!isOwnProfile && cachedWritings.length > 0) { + // For own profile, use centralized controller + if (isOwnProfile) { + await writingsController.start({ + relayPool, + eventStore, + pubkey: viewingPubkey, + force: refreshTrigger > 0 + }) + setLoadedTabs(prev => new Set(prev).add('writings')) + return + } + + // For other profiles, seed with cached writings first + if (cachedWritings.length > 0) { setWritings(cachedWritings.sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at @@ -213,7 +244,7 @@ const Me: React.FC = ({ })) } - // Fetch fresh writings + // Fetch fresh writings for other profiles const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) setWritings(userWritings) setLoadedTabs(prev => new Set(prev).add('writings')) @@ -375,6 +406,13 @@ const Me: React.FC = ({ } }, [isOwnProfile, myHighlights]) + // Sync myWritings from controller when viewing own profile + useEffect(() => { + if (isOwnProfile) { + setWritings(myWritings) + } + }, [isOwnProfile, myWritings]) + // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { diff --git a/src/services/writingsController.ts b/src/services/writingsController.ts new file mode 100644 index 00000000..992a2aac --- /dev/null +++ b/src/services/writingsController.ts @@ -0,0 +1,250 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore, Helpers } from 'applesauce-core' +import { NostrEvent } from 'nostr-tools' +import { KINDS } from '../config/kinds' +import { queryEvents } from './dataFetch' +import { BlogPostPreview } from './exploreService' + +const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers + +type WritingsCallback = (posts: BlogPostPreview[]) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'writings_last_synced' + +/** + * Shared writings controller + * Manages the user's nostr-native long-form articles (kind:30023) centrally, + * similar to highlightsController + */ +class WritingsController { + private writingsListeners: WritingsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentPosts: BlogPostPreview[] = [] + private lastLoadedPubkey: string | null = null + private generation = 0 + + onWritings(cb: WritingsCallback): () => void { + this.writingsListeners.push(cb) + return () => { + this.writingsListeners = this.writingsListeners.filter(l => l !== cb) + } + } + + onLoading(cb: LoadingCallback): () => void { + this.loadingListeners.push(cb) + return () => { + this.loadingListeners = this.loadingListeners.filter(l => l !== cb) + } + } + + private setLoading(loading: boolean): void { + this.loadingListeners.forEach(cb => cb(loading)) + } + + private emitWritings(posts: BlogPostPreview[]): void { + this.writingsListeners.forEach(cb => cb(posts)) + } + + /** + * Get current writings without triggering a reload + */ + getWritings(): BlogPostPreview[] { + return [...this.currentPosts] + } + + /** + * Check if writings are loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey && this.currentPosts.length >= 0 + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.generation++ + this.currentPosts = [] + this.lastLoadedPubkey = null + this.emitWritings(this.currentPosts) + } + + /** + * Get last synced timestamp for incremental loading + */ + private getLastSyncedAt(pubkey: string): number | null { + try { + const data = localStorage.getItem(LAST_SYNCED_KEY) + if (!data) return null + const parsed = JSON.parse(data) + return parsed[pubkey] || null + } catch { + return null + } + } + + /** + * Update last synced timestamp + */ + private setLastSyncedAt(pubkey: string, timestamp: number): void { + try { + const data = localStorage.getItem(LAST_SYNCED_KEY) + const parsed = data ? JSON.parse(data) : {} + parsed[pubkey] = timestamp + localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed)) + } catch (err) { + console.warn('[writings] Failed to save last synced timestamp:', err) + } + } + + /** + * Convert NostrEvent to BlogPostPreview using applesauce Helpers + */ + private toPreview(event: NostrEvent): BlogPostPreview { + return { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } + } + + /** + * Sort posts by published/created date (most recent first) + */ + private sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] { + return posts.slice().sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + } + + /** + * Load writings for a user (kind:30023) + * Streams results and stores in event store + */ + async start(options: { + relayPool: RelayPool + eventStore: IEventStore + pubkey: string + force?: boolean + }): Promise { + const { relayPool, eventStore, pubkey, force = false } = options + + // Skip if already loaded for this pubkey (unless forced) + if (!force && this.isLoadedFor(pubkey)) { + console.log('[writings] ✅ Already loaded for', pubkey.slice(0, 8)) + this.emitWritings(this.currentPosts) + return + } + + // Increment generation to cancel any in-flight work + this.generation++ + const currentGeneration = this.generation + + this.setLoading(true) + console.log('[writings] 🔍 Loading writings for', pubkey.slice(0, 8)) + + try { + const seenIds = new Set() + const uniqueByReplaceable = new Map() + + // Get last synced timestamp for incremental loading + const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey) + const filter: { kinds: number[]; authors: string[]; since?: number } = { + kinds: [KINDS.BlogPost], + authors: [pubkey] + } + if (lastSyncedAt) { + filter.since = lastSyncedAt + console.log('[writings] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString()) + } + + const events = await queryEvents( + relayPool, + filter, + { + onEvent: (evt) => { + // Check if this generation is still active + if (currentGeneration !== this.generation) return + + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + // Store in event store immediately + eventStore.add(evt) + + // Dedupe by replaceable key (author + d-tag) + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${evt.pubkey}:${dTag}` + + const preview = this.toPreview(evt) + const existing = uniqueByReplaceable.get(key) + + // Keep the newest version for replaceable events + if (!existing || evt.created_at > existing.event.created_at) { + uniqueByReplaceable.set(key, preview) + + // Stream to listeners + const sortedPosts = this.sortPosts(Array.from(uniqueByReplaceable.values())) + this.currentPosts = sortedPosts + this.emitWritings(sortedPosts) + } + } + } + ) + + // Check if still active after async operation + if (currentGeneration !== this.generation) { + console.log('[writings] ⚠️ Load cancelled (generation mismatch)') + return + } + + // Store all events in event store + events.forEach(evt => eventStore.add(evt)) + + // Final processing - ensure we have the latest version of each replaceable + events.forEach(evt => { + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${evt.pubkey}:${dTag}` + const existing = uniqueByReplaceable.get(key) + + if (!existing || evt.created_at > existing.event.created_at) { + uniqueByReplaceable.set(key, this.toPreview(evt)) + } + }) + + const sorted = this.sortPosts(Array.from(uniqueByReplaceable.values())) + + this.currentPosts = sorted + this.lastLoadedPubkey = pubkey + this.emitWritings(sorted) + + // Update last synced timestamp + if (sorted.length > 0) { + const newestTimestamp = Math.max(...sorted.map(p => p.event.created_at)) + this.setLastSyncedAt(pubkey, newestTimestamp) + } + + console.log('[writings] ✅ Loaded', sorted.length, 'writings') + } catch (error) { + console.error('[writings] ❌ Failed to load writings:', error) + this.currentPosts = [] + this.emitWritings(this.currentPosts) + } finally { + // Only clear loading if this generation is still active + if (currentGeneration === this.generation) { + this.setLoading(false) + } + } + } +} + +// Singleton instance +export const writingsController = new WritingsController() + From 936f9093cf43d11da6169e9cf5dfc1f55d7f16e8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:45:16 +0200 Subject: [PATCH 02/22] fix(me): use myWritingsLoading state in writings tab rendering --- src/components/Me.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 6220f1d2..c1af4ab6 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -752,7 +752,7 @@ const Me: React.FC = ({ ) } - return writings.length === 0 && !loading ? ( + return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? (
No articles written yet.
From 20b4f2b1b2d8677a1a5413ec00e02fd23f03057f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:50:12 +0200 Subject: [PATCH 03/22] fix(explore): fetch nostrverse content when logged out - Allow exploring nostrverse writings and highlights without account - Default to nostrverse visibility when logged out - Update visibility settings when login state changes --- src/components/Explore.tsx | 38 +++++++++++++++++++++++++++++++------- 1 file changed, 31 insertions(+), 7 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1b6069b6..5c303e5b 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -67,9 +67,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, []) - // Visibility filters (defaults from settings) + // Visibility filters (defaults from settings or nostrverse when logged out) const [visibility, setVisibility] = useState({ - nostrverse: settings?.defaultExploreScopeNostrverse ?? false, + nostrverse: activeAccount ? (settings?.defaultExploreScopeNostrverse ?? false) : true, friends: settings?.defaultExploreScopeFriends ?? true, mine: settings?.defaultExploreScopeMine ?? false }) @@ -84,6 +84,21 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } }, []) + // Update visibility when login state changes + useEffect(() => { + if (!activeAccount) { + // When logged out, show nostrverse by default + setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false })) + } else { + // When logged in, use settings defaults + setVisibility({ + nostrverse: settings?.defaultExploreScopeNostrverse ?? false, + friends: settings?.defaultExploreScopeFriends ?? true, + mine: settings?.defaultExploreScopeMine ?? false + }) + } + }, [activeAccount, settings]) + // Update local state when prop changes useEffect(() => { if (propActiveTab) { @@ -93,15 +108,24 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti useEffect(() => { const loadData = async () => { - if (!activeAccount) { - setLoading(false) - return - } - try { // show spinner but keep existing data setLoading(true) + // If not logged in, only fetch nostrverse content + if (!activeAccount) { + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const [nostrversePosts, nostriverseHighlights] = await Promise.all([ + fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), + fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) + ]) + + setBlogPosts(nostrversePosts) + setHighlights(nostriverseHighlights) + setLoading(false) + return + } + // Seed from in-memory cache if available to avoid empty flash const memoryCachedPosts = getCachedPosts(activeAccount.pubkey) if (memoryCachedPosts && memoryCachedPosts.length > 0) { From 179fe0bbc22bd19843896f711542c07dfda8741c Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:54:02 +0200 Subject: [PATCH 04/22] fix(explore): prevent infinite loop when loading nostrverse content - Remove cachedHighlights, cachedWritings, myHighlights from useEffect deps - These are derived from eventStore and caused infinite refetch loop - Content is still seeded from cache but doesn't trigger re-fetches --- src/components/Explore.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 5c303e5b..44262a4d 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -319,7 +319,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ From c79f4122dae9c4efab0d4c1baec7689c3c8294af Mon Sep 17 00:00:00 2001 From: Gigi Date: Sat, 18 Oct 2025 23:57:46 +0200 Subject: [PATCH 05/22] feat(debug): add Writings Loading section to debug page - Add handlers for loading my writings, friends writings, and nostrverse writings - Display writings with title, summary, author, and d-tag - Show timing metrics (total load time and first event time) - Use writingsController for own writings to test controller functionality --- src/components/Debug.tsx | 283 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 283 insertions(+) diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index cfde73b3..b6665da1 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -18,6 +18,8 @@ import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' import { contactsController } from '../services/contactsController' +import { writingsController } from '../services/writingsController' +import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' const defaultPayload = 'The quick brown fox jumps over the lazy dog.' @@ -94,6 +96,12 @@ const Debug: React.FC = ({ const [tLoadHighlights, setTLoadHighlights] = useState(null) const [tFirstHighlight, setTFirstHighlight] = useState(null) + // Writings loading state + const [isLoadingWritings, setIsLoadingWritings] = useState(false) + const [writingPosts, setWritingPosts] = useState([]) + const [tLoadWritings, setTLoadWritings] = useState(null) + const [tFirstWriting, setTFirstWriting] = useState(null) + // Live timing state const [liveTiming, setLiveTiming] = useState<{ nip44?: { type: 'encrypt' | 'decrypt'; startTime: number } @@ -538,6 +546,188 @@ const Debug: React.FC = ({ } } + const handleLoadMyWritings = async () => { + if (!relayPool || !activeAccount?.pubkey || !eventStore) { + DebugBus.warn('debug', 'Please log in to load your writings') + return + } + const start = performance.now() + setWritingPosts([]) + setIsLoadingWritings(true) + setTLoadWritings(null) + setTFirstWriting(null) + DebugBus.info('debug', 'Loading my writings via writingsController...') + try { + let firstEventTime: number | null = null + const unsub = writingsController.onWritings((posts) => { + if (firstEventTime === null && posts.length > 0) { + firstEventTime = performance.now() - start + setTFirstWriting(Math.round(firstEventTime)) + } + setWritingPosts(posts) + }) + + await writingsController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: true + }) + + unsub() + const currentWritings = writingsController.getWritings() + setWritingPosts(currentWritings) + DebugBus.info('debug', `Loaded ${currentWritings.length} writings via controller`) + } finally { + setIsLoadingWritings(false) + const elapsed = Math.round(performance.now() - start) + setTLoadWritings(elapsed) + DebugBus.info('debug', `Loaded my writings in ${elapsed}ms`) + } + } + + const handleLoadFriendsWritings = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load friends writings') + return + } + const start = performance.now() + setWritingPosts([]) + setIsLoadingWritings(true) + setTLoadWritings(null) + setTFirstWriting(null) + DebugBus.info('debug', 'Loading friends writings...') + try { + // Get contacts first + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey }) + const friends = contactsController.getContacts() + const friendsArray = Array.from(friends) + DebugBus.info('debug', `Found ${friendsArray.length} friends`) + + if (friendsArray.length === 0) { + DebugBus.warn('debug', 'No friends found to load writings from') + return + } + + let firstEventTime: number | null = null + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + const posts = await fetchBlogPostsFromAuthors( + relayPool, + friendsArray, + relayUrls, + (post) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstWriting(Math.round(firstEventTime)) + } + setWritingPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const exists = prev.find(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (exists) return prev + return [...prev, post].sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + } + ) + + setWritingPosts(posts) + DebugBus.info('debug', `Loaded ${posts.length} friend writings`) + } finally { + setIsLoadingWritings(false) + const elapsed = Math.round(performance.now() - start) + setTLoadWritings(elapsed) + DebugBus.info('debug', `Loaded friend writings in ${elapsed}ms`) + } + } + + const handleLoadNostrverseWritings = async () => { + if (!relayPool) { + DebugBus.warn('debug', 'Relay pool not available') + return + } + const start = performance.now() + setWritingPosts([]) + setIsLoadingWritings(true) + setTLoadWritings(null) + setTFirstWriting(null) + DebugBus.info('debug', 'Loading nostrverse writings (kind:30023)...') + try { + let firstEventTime: number | null = null + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + + const { queryEvents } = await import('../services/dataFetch') + const { Helpers } = await import('applesauce-core') + const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers + + const uniqueEvents = new Map() + await queryEvents(relayPool, { kinds: [30023], limit: 50 }, { + relayUrls, + onEvent: (evt) => { + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${evt.pubkey}:${dTag}` + const existing = uniqueEvents.get(key) + if (!existing || evt.created_at > existing.created_at) { + uniqueEvents.set(key, evt) + + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstWriting(Math.round(firstEventTime)) + } + + const posts = Array.from(uniqueEvents.values()).map(event => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } as BlogPostPreview)).sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + + setWritingPosts(posts) + } + } + }) + + const finalPosts = Array.from(uniqueEvents.values()).map(event => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } as BlogPostPreview)).sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + + setWritingPosts(finalPosts) + DebugBus.info('debug', `Loaded ${finalPosts.length} nostrverse writings`) + } finally { + setIsLoadingWritings(false) + const elapsed = Math.round(performance.now() - start) + setTLoadWritings(elapsed) + DebugBus.info('debug', `Loaded nostrverse writings in ${elapsed}ms`) + } + } + + const handleClearWritings = () => { + setWritingPosts([]) + setTLoadWritings(null) + setTFirstWriting(null) + } + const handleLoadFriendsList = async () => { if (!relayPool || !activeAccount?.pubkey) { DebugBus.warn('debug', 'Please log in to load friends list') @@ -1070,6 +1260,99 @@ const Debug: React.FC = ({ )} + {/* Writings Loading Section */} +
+

Writings Loading

+ +
Quick load options:
+
+ + + + +
+ +
+ + +
+ + {writingPosts.length > 0 && ( +
+
Loaded Writings ({writingPosts.length}):
+
+ {writingPosts.map((post, idx) => { + const title = post.title + const summary = post.summary + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + + return ( +
+
Writing #{idx + 1}
+
+
Author: {post.author.slice(0, 16)}...
+
Published: {post.published ? new Date(post.published * 1000).toLocaleString() : new Date(post.event.created_at * 1000).toLocaleString()}
+
d-tag: {dTag || '(empty)'}
+
+
+
Title:
+
"{title}"
+
+ {summary && ( +
+
Summary: {summary.substring(0, 100)}{summary.length > 100 ? '...' : ''}
+
+ )} + {post.image && ( +
+
Image: {post.image.substring(0, 40)}...
+
+ )} +
ID: {post.event.id}
+
+ ) + })} +
+
+ )} +
+ {/* Web of Trust Section */}

Web of Trust

From 83076e7b01d7541a56bf12103b8674b439e36ee3 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:04:53 +0200 Subject: [PATCH 06/22] feat(explore): stream nostrverse writings to paint instantly\n\n- Add onPost streaming callback to fetchNostrverseBlogPosts\n- Stream posts in Explore when logged out and logged in\n- Keep final deduped/sorted list after stream completes --- src/components/Explore.tsx | 68 +++++++++++++++++++++++++++---- src/services/nostrverseService.ts | 16 +++++++- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 44262a4d..87df8ca5 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -112,15 +112,50 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // show spinner but keep existing data setLoading(true) - // If not logged in, only fetch nostrverse content + // If not logged in, only fetch nostrverse content with streaming posts if (!activeAccount) { const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const [nostrversePosts, nostriverseHighlights] = await Promise.all([ - fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), - fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) - ]) + const highlightPromise = fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) - setBlogPosts(nostrversePosts) + // Stream posts as they arrive + const postsPromise = fetchNostrverseBlogPosts( + relayPool, + relayUrls, + 50, + eventStore || undefined, + (post) => { + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + } + ) + + const [finalPosts, nostriverseHighlights] = await Promise.all([postsPromise, highlightPromise]) + // Ensure final sorted list set (in case stream missed an update) + setBlogPosts(prev => { + const byKey = new Map() + for (const p of [...prev, ...finalPosts]) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) + } + return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) setHighlights(nostriverseHighlights) setLoading(false) return @@ -280,7 +315,26 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchHighlightsFromAuthors(relayPool, contactsArray), - fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), + fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { + // Stream nostrverse posts too when logged in + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + }), fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) ]) diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index c1e98841..2f96942d 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -20,7 +20,8 @@ export const fetchNostrverseBlogPosts = async ( relayPool: RelayPool, relayUrls: string[], limit = 50, - eventStore?: IEventStore + eventStore?: IEventStore, + onPost?: (post: BlogPostPreview) => void ): Promise => { try { console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit) @@ -44,6 +45,19 @@ export const fetchNostrverseBlogPosts = async ( const existing = uniqueEvents.get(key) if (!existing || event.created_at > existing.created_at) { uniqueEvents.set(key, event) + + // Stream post immediately if callback provided + if (onPost) { + const post: BlogPostPreview = { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } + onPost(post) + } } } } From 5e7395652fdcfc3ec9644a6951043c4a9d3e7b17 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:08:06 +0200 Subject: [PATCH 07/22] feat(explore): stream nostrverse writings when toggled on while logged in\n\n- Lazy-load nostrverse via onPost callback when filter is enabled\n- Avoid reloading twice using hasLoadedNostrverse guard\n- Keep DRY dedupe/sort behavior --- src/components/Explore.tsx | 93 +++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 21 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 87df8ca5..b5f56fb7 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -48,6 +48,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [loading, setLoading] = useState(true) const [refreshTrigger, setRefreshTrigger] = useState(0) + const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false) // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) @@ -89,6 +90,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti if (!activeAccount) { // When logged out, show nostrverse by default setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false })) + setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately } else { // When logged in, use settings defaults setVisibility({ @@ -96,6 +98,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti friends: settings?.defaultExploreScopeFriends ?? true, mine: settings?.defaultExploreScopeMine ?? false }) + setHasLoadedNostrverse(false) } }, [activeAccount, settings]) @@ -309,32 +312,36 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Store final followed pubkeys setFollowedPubkeys(contacts) - // Fetch both friends content and nostrverse content in parallel + // Fetch friends content and (optionally) nostrverse content in parallel const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const contactsArray = Array.from(contacts) + const nostrversePostsPromise = visibility.nostrverse + ? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { + // Stream nostrverse posts too when logged in + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + }) + : Promise.resolve([] as BlogPostPreview[]) + const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchHighlightsFromAuthors(relayPool, contactsArray), - fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { - // Stream nostrverse posts too when logged in - setBlogPosts(prev => { - const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${post.author}:${dTag}` - const existingIndex = prev.findIndex(p => { - const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - return `${p.author}:${pDTag}` === key - }) - if (existingIndex >= 0) { - const existing = prev[existingIndex] - if (post.event.created_at <= existing.event.created_at) return prev - const next = [...prev] - next[existingIndex] = post - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - } - const next = [...prev, post] - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - }), + nostrversePostsPromise, fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) ]) @@ -376,6 +383,50 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // eslint-disable-next-line react-hooks/exhaustive-deps }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) + // Lazy-load nostrverse writings when user toggles it on (logged in) + useEffect(() => { + if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverse) return + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + setHasLoadedNostrverse(true) + fetchNostrverseBlogPosts( + relayPool, + relayUrls, + 50, + eventStore || undefined, + (post) => { + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + } + ).then((finalPosts) => { + // Ensure final deduped list + setBlogPosts(prev => { + const byKey = new Map() + for (const p of [...prev, ...finalPosts]) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) + } + return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + }).catch(() => {}) + }, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse]) + // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { From 18c6c3e68a929ac5be663d135dcadc8be5cb7358 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:12:49 +0200 Subject: [PATCH 08/22] fix(content): show only article-specific highlights in ContentPanel for nostr articles --- src/components/ThreePaneLayout.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index 2ebc0025..d535ed18 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -368,7 +368,9 @@ const ThreePaneLayout: React.FC = (props) => { summary={props.readerContent?.summary} published={props.readerContent?.published} selectedUrl={props.selectedUrl} - highlights={props.classifiedHighlights} + highlights={props.selectedUrl && props.selectedUrl.startsWith('nostr:') + ? props.highlights // article-specific highlights only + : props.classifiedHighlights} showHighlights={props.showHighlights} highlightStyle={props.settings.highlightStyle || 'marker'} highlightColor={props.settings.highlightColor || '#ffff00'} From a799709e629b8a165feb6daa18f80e0d5186f522 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:14:20 +0200 Subject: [PATCH 09/22] fix(explore): ensure writings are deduped by replaceable before visibility filtering and render --- src/components/Explore.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index b5f56fb7..a4ca0e9d 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -463,10 +463,20 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) }, [highlights, activeAccount?.pubkey, followedPubkeys, visibility]) + // Dedupe and sort posts once for rendering + const uniqueSortedPosts = useMemo(() => { + const unique = dedupeWritingsByReplaceable(blogPosts) + return unique.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }, [blogPosts]) + // Filter blog posts by future dates and visibility, and add level classification const filteredBlogPosts = useMemo(() => { const maxFutureTime = Date.now() / 1000 + (24 * 60 * 60) // 1 day from now - return blogPosts + return uniqueSortedPosts .filter(post => { // Filter out future dates const publishedTime = post.published || post.event.created_at @@ -490,7 +500,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const level: 'mine' | 'friends' | 'nostrverse' = isMine ? 'mine' : isFriend ? 'friends' : 'nostrverse' return { ...post, level } }) - }, [blogPosts, activeAccount, followedPubkeys, visibility]) + }, [uniqueSortedPosts, activeAccount, followedPubkeys, visibility]) const renderTabContent = () => { switch (activeTab) { From c9998984c3cc038d2b7b6be335ce139b81e436ed Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:16:01 +0200 Subject: [PATCH 10/22] feat(explore): include and stream my writings when enabled\n\n- Load my own writings in parallel with friends/nostrverse\n- Lazy-load on 'mine' toggle when logged in\n- Keep dedupe/sort consistent --- src/components/Explore.tsx | 76 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 73 insertions(+), 3 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index a4ca0e9d..f4ee4a5c 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -49,6 +49,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [loading, setLoading] = useState(true) const [refreshTrigger, setRefreshTrigger] = useState(0) const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false) + const [hasLoadedMine, setHasLoadedMine] = useState(false) // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) @@ -312,9 +313,35 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Store final followed pubkeys setFollowedPubkeys(contacts) - // Fetch friends content and (optionally) nostrverse content in parallel + // Fetch friends content and (optionally) nostrverse + mine content in parallel const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const contactsArray = Array.from(contacts) + const myPostsPromise = fetchBlogPostsFromAuthors( + relayPool, + activeAccount ? [activeAccount.pubkey] : [], + relayUrls, + (post) => { + // Stream my posts + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + } + ) + setHasLoadedMine(true) const nostrversePostsPromise = visibility.nostrverse ? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { // Stream nostrverse posts too when logged in @@ -338,7 +365,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) : Promise.resolve([] as BlogPostPreview[]) - const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ + const [myPosts, friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ + myPostsPromise, fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchHighlightsFromAuthors(relayPool, contactsArray), nostrversePostsPromise, @@ -346,7 +374,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti ]) // Merge and deduplicate all posts - const allPosts = [...friendsPosts, ...nostrversePosts] + const allPosts = [...myPosts, ...friendsPosts, ...nostrversePosts] const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at @@ -427,6 +455,48 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }).catch(() => {}) }, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse]) + // Lazy-load my writings when user toggles "mine" on (logged in) + useEffect(() => { + if (!activeAccount || !relayPool || !visibility.mine || hasLoadedMine) return + const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + setHasLoadedMine(true) + fetchBlogPostsFromAuthors( + relayPool, + [activeAccount.pubkey], + relayUrls, + (post) => { + setBlogPosts(prev => { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) return prev + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + } + const next = [...prev, post] + return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + } + ).then((finalPosts) => { + setBlogPosts(prev => { + const byKey = new Map() + for (const p of [...prev, ...finalPosts]) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) + } + return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + }).catch(() => {}) + }, [visibility.mine, activeAccount, relayPool, hasLoadedMine]) + // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ onRefresh: () => { From 41a4abff3734aa053dc4f409d3377ab25a416b83 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:24:37 +0200 Subject: [PATCH 11/22] fix(highlights): scope highlights to current article on /a and /r by deriving coordinate from naddr for early filtering, and ensure sidebar/content only show scoped highlights --- src/hooks/useBookmarksData.ts | 45 +++++++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/src/hooks/useBookmarksData.ts b/src/hooks/useBookmarksData.ts index 048c5b1c..41f38716 100644 --- a/src/hooks/useBookmarksData.ts +++ b/src/hooks/useBookmarksData.ts @@ -11,6 +11,7 @@ import { contactsController } from '../services/contactsController' import { useStoreTimeline } from './useStoreTimeline' import { eventToHighlight } from '../services/highlightEventProcessor' import { KINDS } from '../config/kinds' +import { nip19 } from 'nostr-tools' interface UseBookmarksDataParams { relayPool: RelayPool | null @@ -44,21 +45,38 @@ export const useBookmarksData = ({ const [isRefreshing, setIsRefreshing] = useState(false) const [lastFetchTime, setLastFetchTime] = useState(null) + // Determine effective article coordinate as early as possible + // Prefer state-derived coordinate, but fall back to route naddr before content loads + const effectiveArticleCoordinate = useMemo(() => { + if (currentArticleCoordinate) return currentArticleCoordinate + if (!naddr) return undefined + try { + const decoded = nip19.decode(naddr) + if (decoded.type === 'naddr') { + const ptr = decoded.data as { kind: number; pubkey: string; identifier: string } + return `${ptr.kind}:${ptr.pubkey}:${ptr.identifier}` + } + } catch { + // ignore decode failure; treat as no coordinate yet + } + return undefined + }, [currentArticleCoordinate, naddr]) + // Load cached article-specific highlights from event store const articleFilter = useMemo(() => { - if (!currentArticleCoordinate) return null + if (!effectiveArticleCoordinate) return null return { kinds: [KINDS.Highlights], - '#a': [currentArticleCoordinate], + '#a': [effectiveArticleCoordinate], ...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {}) } - }, [currentArticleCoordinate, currentArticleEventId]) + }, [effectiveArticleCoordinate, currentArticleEventId]) const cachedArticleHighlights = useStoreTimeline( eventStore || null, articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article eventToHighlight, - [currentArticleCoordinate, currentArticleEventId] + [effectiveArticleCoordinate, currentArticleEventId] ) // Subscribe to centralized controllers @@ -84,7 +102,7 @@ export const useBookmarksData = ({ setHighlightsLoading(true) try { - if (currentArticleCoordinate) { + if (effectiveArticleCoordinate) { // Seed with cached highlights first if (cachedArticleHighlights.length > 0) { setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at)) @@ -97,7 +115,7 @@ export const useBookmarksData = ({ await fetchHighlightsForArticle( relayPool, - currentArticleCoordinate, + effectiveArticleCoordinate, currentArticleEventId, (highlight) => { // Deduplicate highlights by ID as they arrive @@ -120,7 +138,7 @@ export const useBookmarksData = ({ } finally { setHighlightsLoading(false) } - }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights]) + }, [relayPool, effectiveArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights]) const handleRefreshAll = useCallback(async () => { if (!relayPool || !activeAccount || isRefreshing) return @@ -143,19 +161,20 @@ export const useBookmarksData = ({ if (!relayPool || !activeAccount) return // Fetch article-specific highlights when viewing an article // External URLs have their highlights fetched by useExternalUrlLoader - if (currentArticleCoordinate && !externalUrl) { + if (effectiveArticleCoordinate && !externalUrl) { handleFetchHighlights() } else if (!naddr && !externalUrl) { // Clear article highlights when not viewing an article setArticleHighlights([]) setHighlightsLoading(false) } - }, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights]) + }, [relayPool, activeAccount, effectiveArticleCoordinate, naddr, externalUrl, handleFetchHighlights]) - // Merge highlights from controller with article-specific highlights - const highlights = [...myHighlights, ...articleHighlights] - .filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate - .sort((a, b) => b.created_at - a.created_at) + // When viewing an article, show only article-specific highlights + // Otherwise, show user's highlights from controller + const highlights = effectiveArticleCoordinate || externalUrl + ? articleHighlights.sort((a, b) => b.created_at - a.created_at) + : myHighlights return { highlights, From 07aea9d35f855367d910879a2ee2ed7d64cdb05f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:28:55 +0200 Subject: [PATCH 12/22] fix(explore): prevent disabling all explore scopes; ensure at least one filter remains active --- src/components/Explore.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index f4ee4a5c..3553d7bf 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -76,6 +76,17 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti mine: settings?.defaultExploreScopeMine ?? false }) + // Ensure at least one scope remains active + const toggleScope = useCallback((key: 'nostrverse' | 'friends' | 'mine') => { + setVisibility(prev => { + const next = { ...prev, [key]: !prev[key] } + if (!next.nostrverse && !next.friends && !next.mine) { + return prev // ignore toggle that would disable all scopes + } + return next + }) + }, []) + // Subscribe to highlights controller useEffect(() => { const unsubHighlights = highlightsController.onHighlights(setMyHighlights) @@ -661,7 +672,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti /> setVisibility({ ...visibility, nostrverse: !visibility.nostrverse })} + onClick={() => toggleScope('nostrverse')} title="Toggle nostrverse content" ariaLabel="Toggle nostrverse content" variant="ghost" @@ -672,7 +683,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti /> setVisibility({ ...visibility, friends: !visibility.friends })} + onClick={() => toggleScope('friends')} title={activeAccount ? "Toggle friends content" : "Login to see friends content"} ariaLabel="Toggle friends content" variant="ghost" @@ -684,7 +695,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti /> setVisibility({ ...visibility, mine: !visibility.mine })} + onClick={() => toggleScope('mine')} title={activeAccount ? "Toggle my content" : "Login to see your content"} ariaLabel="Toggle my content" variant="ghost" From 30b8f1af9246f6e9b06b5480f6a5857e97a2a3c0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:30:07 +0200 Subject: [PATCH 13/22] feat(writings): auto-load user writings at login so Explore 'mine' tab has local data --- src/App.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index ee0f1e95..3b454fd2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,7 @@ import { Bookmark } from './types/bookmarks' import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' import { highlightsController } from './services/highlightsController' +import { writingsController } from './services/writingsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -109,6 +110,12 @@ function AppRoutes({ console.log('[highlights] 🚀 Auto-loading highlights on mount/login') highlightsController.start({ relayPool, eventStore, pubkey }) } + + // Load writings (controller manages its own state) + if (pubkey && eventStore && !writingsController.isLoadedFor(pubkey)) { + console.log('[writings] 🚀 Auto-loading writings on mount/login') + writingsController.start({ relayPool, eventStore, pubkey }) + } } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) From 0baf75462c14eaf0ffa281fbca8d269771cb847b Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:34:21 +0200 Subject: [PATCH 14/22] refactor(explore): use writingsController for 'mine' posts; keep fetches non-blocking and centralized --- src/components/Explore.tsx | 99 ++++++++++++++------------------------ 1 file changed, 36 insertions(+), 63 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 3553d7bf..2b14e94c 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -27,6 +27,7 @@ import { KINDS } from '../config/kinds' import { eventToHighlight } from '../services/highlightEventProcessor' import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' +import { writingsController } from '../services/writingsController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -97,6 +98,36 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } }, []) + // Subscribe to writings controller for "mine" posts and seed immediately + useEffect(() => { + // Seed from controller's current state + const seed = writingsController.getWritings() + if (seed.length > 0) { + setBlogPosts(prev => { + const merged = dedupeWritingsByReplaceable([...prev, ...seed]) + return merged.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + } + + // Stream updates + const unsub = writingsController.onWritings((posts) => { + setBlogPosts(prev => { + const merged = dedupeWritingsByReplaceable([...prev, ...posts]) + return merged.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) + }) + + return () => unsub() + }, []) + // Update visibility when login state changes useEffect(() => { if (!activeAccount) { @@ -327,31 +358,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Fetch friends content and (optionally) nostrverse + mine content in parallel const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const contactsArray = Array.from(contacts) - const myPostsPromise = fetchBlogPostsFromAuthors( - relayPool, - activeAccount ? [activeAccount.pubkey] : [], - relayUrls, - (post) => { - // Stream my posts - setBlogPosts(prev => { - const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${post.author}:${dTag}` - const existingIndex = prev.findIndex(p => { - const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - return `${p.author}:${pDTag}` === key - }) - if (existingIndex >= 0) { - const existing = prev[existingIndex] - if (post.event.created_at <= existing.event.created_at) return prev - const next = [...prev] - next[existingIndex] = post - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - } - const next = [...prev, post] - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - } - ) + // Use centralized writingsController for my posts (non-blocking) + const myPostsPromise: Promise = Promise.resolve(writingsController.getWritings()) setHasLoadedMine(true) const nostrversePostsPromise = visibility.nostrverse ? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { @@ -467,46 +475,11 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse]) // Lazy-load my writings when user toggles "mine" on (logged in) + // No direct fetch here; writingsController streams my posts centrally useEffect(() => { - if (!activeAccount || !relayPool || !visibility.mine || hasLoadedMine) return - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) + if (!activeAccount || !visibility.mine || hasLoadedMine) return setHasLoadedMine(true) - fetchBlogPostsFromAuthors( - relayPool, - [activeAccount.pubkey], - relayUrls, - (post) => { - setBlogPosts(prev => { - const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${post.author}:${dTag}` - const existingIndex = prev.findIndex(p => { - const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - return `${p.author}:${pDTag}` === key - }) - if (existingIndex >= 0) { - const existing = prev[existingIndex] - if (post.event.created_at <= existing.event.created_at) return prev - const next = [...prev] - next[existingIndex] = post - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - } - const next = [...prev, post] - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - } - ).then((finalPosts) => { - setBlogPosts(prev => { - const byKey = new Map() - for (const p of [...prev, ...finalPosts]) { - const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${p.author}:${dTag}` - const existing = byKey.get(key) - if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) - } - return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - }).catch(() => {}) - }, [visibility.mine, activeAccount, relayPool, hasLoadedMine]) + }, [visibility.mine, activeAccount, hasLoadedMine]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ From 9a437dd97bcfe7863d7409193160675e3864490d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:38:05 +0200 Subject: [PATCH 15/22] fix(explore): ensure nostrverse highlights are loaded and merged; preload nostrverse highlights at app start for instant Explore toggle --- src/App.tsx | 14 ++++++++++++++ src/components/Explore.tsx | 16 ++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 3b454fd2..13cc00ef 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' import { highlightsController } from './services/highlightsController' import { writingsController } from './services/writingsController' +import { fetchNostrverseHighlights } from './services/nostrverseService' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -116,6 +117,19 @@ function AppRoutes({ console.log('[writings] 🚀 Auto-loading writings on mount/login') writingsController.start({ relayPool, eventStore, pubkey }) } + + // Preload some nostrverse highlights into the event store (non-blocking) + // so Explore can show nostrverse immediately when toggled + if (eventStore) { + try { + // Fire-and-forget + ;(async () => { + try { + await fetchNostrverseHighlights(relayPool, 100, eventStore) + } catch (e) { /* ignore */ } + })() + } catch { /* ignore */ } + } } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 2b14e94c..9891a35c 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -51,6 +51,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [refreshTrigger, setRefreshTrigger] = useState(0) const [hasLoadedNostrverse, setHasLoadedNostrverse] = useState(false) const [hasLoadedMine, setHasLoadedMine] = useState(false) + const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false) // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) @@ -134,6 +135,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // When logged out, show nostrverse by default setVisibility(prev => ({ ...prev, nostrverse: true, friends: false, mine: false })) setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately + setHasLoadedNostrverseHighlights(true) } else { // When logged in, use settings defaults setVisibility({ @@ -142,6 +144,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti mine: settings?.defaultExploreScopeMine ?? false }) setHasLoadedNostrverse(false) + setHasLoadedNostrverseHighlights(false) } }, [activeAccount, settings]) @@ -474,6 +477,19 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }).catch(() => {}) }, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverse]) + // Lazy-load nostrverse highlights when user toggles it on (logged in) + useEffect(() => { + if (!activeAccount || !relayPool || !visibility.nostrverse || hasLoadedNostrverseHighlights) return + setHasLoadedNostrverseHighlights(true) + fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) + .then((hl) => { + if (hl && hl.length > 0) { + setHighlights(prev => dedupeHighlightsById([...prev, ...hl]).sort((a, b) => b.created_at - a.created_at)) + } + }) + .catch(() => {}) + }, [visibility.nostrverse, activeAccount, relayPool, eventStore, hasLoadedNostrverseHighlights]) + // Lazy-load my writings when user toggles "mine" on (logged in) // No direct fetch here; writingsController streams my posts centrally useEffect(() => { From 23526954eaabcf9e926eaa36a6eb150397edfc53 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:42:39 +0200 Subject: [PATCH 16/22] fix(explore): reflect settings default scope immediately and avoid blank lists; preload/merge nostrverse from event store and keep fetches non-blocking --- src/components/Explore.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 9891a35c..c34fc825 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -70,6 +70,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }), []) const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, []) + + // Visibility filters (defaults from settings or nostrverse when logged out) const [visibility, setVisibility] = useState({ @@ -129,7 +131,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) - // Update visibility when login state changes + // Update visibility when settings/login state changes useEffect(() => { if (!activeAccount) { // When logged out, show nostrverse by default @@ -137,7 +139,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setHasLoadedNostrverse(true) // logged out path loads nostrverse immediately setHasLoadedNostrverseHighlights(true) } else { - // When logged in, use settings defaults + // When logged in, use settings defaults immediately setVisibility({ nostrverse: settings?.defaultExploreScopeNostrverse ?? false, friends: settings?.defaultExploreScopeFriends ?? true, @@ -146,7 +148,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setHasLoadedNostrverse(false) setHasLoadedNostrverseHighlights(false) } - }, [activeAccount, settings]) + }, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine]) // Update local state when prop changes useEffect(() => { From 6c00904bd50e3c2c5bdf95840e43098b762c3ff8 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:46:16 +0200 Subject: [PATCH 17/22] fix(explore,nostrverse): never block explore highlights on nostrverse; show empty state instead of spinner and stream results into store immediately --- src/components/Explore.tsx | 2 +- src/services/nostrverseService.ts | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index c34fc825..77d7e8da 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -615,7 +615,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } return classifiedHighlights.length === 0 ? (
- + No highlights to show for the selected scope.
) : (
diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 2f96942d..4374e9cf 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -106,6 +106,8 @@ export const fetchNostrverseHighlights = async ( console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit) const seenIds = new Set() + // Collect but do not block callers awaiting network completion + const collected: NostrEvent[] = [] const rawEvents = await queryEvents( relayPool, { kinds: [9802], limit }, @@ -118,6 +120,7 @@ export const fetchNostrverseHighlights = async ( if (eventStore) { eventStore.add(event) } + collected.push(event) } } ) @@ -127,7 +130,7 @@ export const fetchNostrverseHighlights = async ( rawEvents.forEach(evt => eventStore.add(evt)) } - const uniqueEvents = dedupeHighlights(rawEvents) + const uniqueEvents = dedupeHighlights([...collected, ...rawEvents]) const highlights = uniqueEvents.map(eventToHighlight) console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights') From 8aa26caae0ef16da6351f1d04d4554ce82706a25 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:48:24 +0200 Subject: [PATCH 18/22] feat(explore): show skeletons instead of spinner; keep nostrverse non-blocking and stream into view --- src/components/Explore.tsx | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 77d7e8da..1379c31c 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -55,7 +55,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Get myHighlights directly from controller const [myHighlights, setMyHighlights] = useState([]) - const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + // Remove unused loading state to avoid warnings // Load cached content from event store (instant display) const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, []) @@ -94,10 +94,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Subscribe to highlights controller useEffect(() => { const unsubHighlights = highlightsController.onHighlights(setMyHighlights) - const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) return () => { unsubHighlights() - unsubLoading() } }, []) @@ -634,9 +632,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } } - // Show content progressively - no blocking error screens + // Show skeletons while first load in this session const hasData = highlights.length > 0 || blogPosts.length > 0 - const showSkeletons = (loading || myHighlightsLoading) && !hasData + const showSkeletons = loading && !hasData return (
From c7f7792d736afd72534cfb380544c98dd469332d Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:50:12 +0200 Subject: [PATCH 19/22] feat(highlights): add centralized nostrverseHighlightsController; start at app init; Explore subscribes to controller stream --- src/App.tsx | 15 +- src/components/Explore.tsx | 16 ++ .../nostrverseHighlightsController.ts | 139 ++++++++++++++++++ 3 files changed, 159 insertions(+), 11 deletions(-) create mode 100644 src/services/nostrverseHighlightsController.ts diff --git a/src/App.tsx b/src/App.tsx index 13cc00ef..fc5b8761 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,7 +24,8 @@ import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' import { highlightsController } from './services/highlightsController' import { writingsController } from './services/writingsController' -import { fetchNostrverseHighlights } from './services/nostrverseService' +// import { fetchNostrverseHighlights } from './services/nostrverseService' +import { nostrverseHighlightsController } from './services/nostrverseHighlightsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -118,17 +119,9 @@ function AppRoutes({ writingsController.start({ relayPool, eventStore, pubkey }) } - // Preload some nostrverse highlights into the event store (non-blocking) - // so Explore can show nostrverse immediately when toggled + // Start centralized nostrverse highlights controller (non-blocking) if (eventStore) { - try { - // Fire-and-forget - ;(async () => { - try { - await fetchNostrverseHighlights(relayPool, 100, eventStore) - } catch (e) { /* ignore */ } - })() - } catch { /* ignore */ } + nostrverseHighlightsController.start({ relayPool, eventStore }) } } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1379c31c..ae82b1fd 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -13,6 +13,7 @@ import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreS import { fetchHighlightsFromAuthors } from '../services/highlightService' import { fetchProfiles } from '../services/profileService' import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService' +import { nostrverseHighlightsController } from '../services/nostrverseHighlightsController' import { highlightsController } from '../services/highlightsController' import { Highlight } from '../types/highlights' import { UserSettings } from '../services/settingsService' @@ -99,6 +100,21 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } }, []) + // Subscribe to nostrverse highlights controller for global stream + useEffect(() => { + const apply = (incoming: Highlight[]) => { + setHighlights(prev => { + const byId = new Map(prev.map(h => [h.id, h])) + for (const h of incoming) byId.set(h.id, h) + return Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) + }) + } + // seed immediately + apply(nostrverseHighlightsController.getHighlights()) + const unsub = nostrverseHighlightsController.onHighlights(apply) + return () => unsub() + }, []) + // Subscribe to writings controller for "mine" posts and seed immediately useEffect(() => { // Seed from controller's current state diff --git a/src/services/nostrverseHighlightsController.ts b/src/services/nostrverseHighlightsController.ts new file mode 100644 index 00000000..4456d97f --- /dev/null +++ b/src/services/nostrverseHighlightsController.ts @@ -0,0 +1,139 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { Highlight } from '../types/highlights' +import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' +import { eventToHighlight, sortHighlights } from './highlightEventProcessor' + +type HighlightsCallback = (highlights: Highlight[]) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'nostrverse_highlights_last_synced' + +class NostrverseHighlightsController { + private highlightsListeners: HighlightsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentHighlights: Highlight[] = [] + private loaded = false + private generation = 0 + + onHighlights(cb: HighlightsCallback): () => void { + this.highlightsListeners.push(cb) + return () => { + this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb) + } + } + + onLoading(cb: LoadingCallback): () => void { + this.loadingListeners.push(cb) + return () => { + this.loadingListeners = this.loadingListeners.filter(l => l !== cb) + } + } + + private setLoading(loading: boolean): void { + this.loadingListeners.forEach(cb => cb(loading)) + } + + private emitHighlights(highlights: Highlight[]): void { + this.highlightsListeners.forEach(cb => cb(highlights)) + } + + getHighlights(): Highlight[] { + return [...this.currentHighlights] + } + + isLoaded(): boolean { + return this.loaded + } + + private getLastSyncedAt(): number | null { + try { + const raw = localStorage.getItem(LAST_SYNCED_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + return typeof parsed?.ts === 'number' ? parsed.ts : null + } catch { + return null + } + } + + private setLastSyncedAt(timestamp: number): void { + try { + localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts: timestamp })) + } catch { /* ignore */ } + } + + async start(options: { + relayPool: RelayPool + eventStore: IEventStore + force?: boolean + }): Promise { + const { relayPool, eventStore, force = false } = options + + if (!force && this.loaded) { + this.emitHighlights(this.currentHighlights) + return + } + + this.generation++ + const currentGeneration = this.generation + this.setLoading(true) + + try { + const seenIds = new Set() + const highlightsMap = new Map() + + const lastSyncedAt = force ? null : this.getLastSyncedAt() + const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.Highlights] } + if (lastSyncedAt) filter.since = lastSyncedAt + + const events = await queryEvents( + relayPool, + filter, + { + onEvent: (evt) => { + if (currentGeneration !== this.generation) return + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + eventStore.add(evt) + const highlight = eventToHighlight(evt) + highlightsMap.set(highlight.id, highlight) + + const sorted = sortHighlights(Array.from(highlightsMap.values())) + this.currentHighlights = sorted + this.emitHighlights(sorted) + } + } + ) + + if (currentGeneration !== this.generation) return + + events.forEach(evt => eventStore.add(evt)) + + const highlights = events.map(eventToHighlight) + const unique = Array.from(new Map(highlights.map(h => [h.id, h])).values()) + const sorted = sortHighlights(unique) + + this.currentHighlights = sorted + this.loaded = true + this.emitHighlights(sorted) + + if (sorted.length > 0) { + const newest = Math.max(...sorted.map(h => h.created_at)) + this.setLastSyncedAt(newest) + } + } catch (err) { + this.currentHighlights = [] + this.emitHighlights(this.currentHighlights) + } finally { + if (currentGeneration === this.generation) this.setLoading(false) + } + } +} + +export const nostrverseHighlightsController = new NostrverseHighlightsController() + + From 35efdb6d3fcd0fafd9fb65887496089dd237572f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:52:32 +0200 Subject: [PATCH 20/22] feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init --- src/App.tsx | 2 + src/components/Explore.tsx | 25 +++ src/services/nostrverseWritingsController.ts | 169 +++++++++++++++++++ 3 files changed, 196 insertions(+) create mode 100644 src/services/nostrverseWritingsController.ts diff --git a/src/App.tsx b/src/App.tsx index fc5b8761..7c7c59e5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -26,6 +26,7 @@ import { highlightsController } from './services/highlightsController' import { writingsController } from './services/writingsController' // import { fetchNostrverseHighlights } from './services/nostrverseService' import { nostrverseHighlightsController } from './services/nostrverseHighlightsController' +import { nostrverseWritingsController } from './services/nostrverseWritingsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -122,6 +123,7 @@ function AppRoutes({ // Start centralized nostrverse highlights controller (non-blocking) if (eventStore) { nostrverseHighlightsController.start({ relayPool, eventStore }) + nostrverseWritingsController.start({ relayPool, eventStore }) } } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index ae82b1fd..a5e821b5 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -29,6 +29,7 @@ import { eventToHighlight } from '../services/highlightEventProcessor' import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { writingsController } from '../services/writingsController' +import { nostrverseWritingsController } from '../services/nostrverseWritingsController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -115,6 +116,30 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) + // Subscribe to nostrverse writings controller for global stream + useEffect(() => { + const apply = (incoming: BlogPostPreview[]) => { + setBlogPosts(prev => { + const byKey = new Map() + for (const p of prev) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + byKey.set(key, p) + } + for (const p of incoming) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) + } + return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + } + apply(nostrverseWritingsController.getWritings()) + const unsub = nostrverseWritingsController.onWritings(apply) + return () => unsub() + }, []) + // Subscribe to writings controller for "mine" posts and seed immediately useEffect(() => { // Seed from controller's current state diff --git a/src/services/nostrverseWritingsController.ts b/src/services/nostrverseWritingsController.ts new file mode 100644 index 00000000..7efdc63e --- /dev/null +++ b/src/services/nostrverseWritingsController.ts @@ -0,0 +1,169 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore, Helpers } from 'applesauce-core' +import { NostrEvent } from 'nostr-tools' +import { KINDS } from '../config/kinds' +import { queryEvents } from './dataFetch' +import { BlogPostPreview } from './exploreService' + +const { getArticleTitle, getArticleSummary, getArticleImage, getArticlePublished } = Helpers + +type WritingsCallback = (posts: BlogPostPreview[]) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'nostrverse_writings_last_synced' + +function toPreview(event: NostrEvent): BlogPostPreview { + return { + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + } +} + +function sortPosts(posts: BlogPostPreview[]): BlogPostPreview[] { + return posts.slice().sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) +} + +class NostrverseWritingsController { + private writingsListeners: WritingsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentPosts: BlogPostPreview[] = [] + private loaded = false + private generation = 0 + + onWritings(cb: WritingsCallback): () => void { + this.writingsListeners.push(cb) + return () => { + this.writingsListeners = this.writingsListeners.filter(l => l !== cb) + } + } + + onLoading(cb: LoadingCallback): () => void { + this.loadingListeners.push(cb) + return () => { + this.loadingListeners = this.loadingListeners.filter(l => l !== cb) + } + } + + private setLoading(loading: boolean): void { + this.loadingListeners.forEach(cb => cb(loading)) + } + + private emitWritings(posts: BlogPostPreview[]): void { + this.writingsListeners.forEach(cb => cb(posts)) + } + + getWritings(): BlogPostPreview[] { + return [...this.currentPosts] + } + + isLoaded(): boolean { + return this.loaded + } + + private getLastSyncedAt(): number | null { + try { + const raw = localStorage.getItem(LAST_SYNCED_KEY) + if (!raw) return null + const parsed = JSON.parse(raw) + return typeof parsed?.ts === 'number' ? parsed.ts : null + } catch { + return null + } + } + + private setLastSyncedAt(ts: number): void { + try { localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify({ ts })) } catch { /* ignore */ } + } + + async start(options: { + relayPool: RelayPool + eventStore: IEventStore + force?: boolean + }): Promise { + const { relayPool, eventStore, force = false } = options + + if (!force && this.loaded) { + this.emitWritings(this.currentPosts) + return + } + + this.generation++ + const currentGeneration = this.generation + this.setLoading(true) + + try { + const seenIds = new Set() + const uniqueByReplaceable = new Map() + + const lastSyncedAt = force ? null : this.getLastSyncedAt() + const filter: { kinds: number[]; since?: number } = { kinds: [KINDS.BlogPost] } + if (lastSyncedAt) filter.since = lastSyncedAt + + const events = await queryEvents( + relayPool, + filter, + { + onEvent: (evt) => { + if (currentGeneration !== this.generation) return + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + eventStore.add(evt) + + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${evt.pubkey}:${dTag}` + const preview = toPreview(evt) + const existing = uniqueByReplaceable.get(key) + if (!existing || evt.created_at > existing.event.created_at) { + uniqueByReplaceable.set(key, preview) + const sorted = sortPosts(Array.from(uniqueByReplaceable.values())) + this.currentPosts = sorted + this.emitWritings(sorted) + } + } + } + ) + + if (currentGeneration !== this.generation) return + + events.forEach(evt => eventStore.add(evt)) + + events.forEach(evt => { + const dTag = evt.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${evt.pubkey}:${dTag}` + const existing = uniqueByReplaceable.get(key) + if (!existing || evt.created_at > existing.event.created_at) { + uniqueByReplaceable.set(key, toPreview(evt)) + } + }) + + const sorted = sortPosts(Array.from(uniqueByReplaceable.values())) + this.currentPosts = sorted + this.loaded = true + this.emitWritings(sorted) + + if (sorted.length > 0) { + const newest = Math.max(...sorted.map(p => p.event.created_at)) + this.setLastSyncedAt(newest) + } + } catch { + this.currentPosts = [] + this.emitWritings(this.currentPosts) + } finally { + if (currentGeneration === this.generation) this.setLoading(false) + } + } +} + +export const nostrverseWritingsController = new NostrverseWritingsController() + + From d32a47e3c349e0955e09f8dd5ec9aaa2fd645aa0 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:55:24 +0200 Subject: [PATCH 21/22] perf(explore): make loading fully non-blocking; seed caches then stream and merge results progressively --- src/components/Explore.tsx | 116 +++++++++++++++++++------------------ 1 file changed, 60 insertions(+), 56 deletions(-) diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index a5e821b5..7abffdce 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -199,7 +199,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti useEffect(() => { const loadData = async () => { try { - // show spinner but keep existing data + // begin load, but do not block rendering setLoading(true) // If not logged in, only fetch nostrverse content with streaming posts @@ -234,29 +234,31 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } ) - const [finalPosts, nostriverseHighlights] = await Promise.all([postsPromise, highlightPromise]) - // Ensure final sorted list set (in case stream missed an update) - setBlogPosts(prev => { - const byKey = new Map() - for (const p of [...prev, ...finalPosts]) { - const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${p.author}:${dTag}` - const existing = byKey.get(key) - if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) - } - return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - setHighlights(nostriverseHighlights) - setLoading(false) - return + // When each finishes, merge without blocking initial render + postsPromise.then((finalPosts) => { + setBlogPosts(prev => { + const byKey = new Map() + for (const p of [...prev, ...finalPosts]) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) + } + return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + }) + }).catch(() => {}) + highlightPromise.then((nostriverseHighlights) => { + setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at)) + }).catch(() => {}) + // drop through; do not early return so post-login path runs seeding too } // Seed from in-memory cache if available to avoid empty flash - const memoryCachedPosts = getCachedPosts(activeAccount.pubkey) + const memoryCachedPosts = activeAccount ? getCachedPosts(activeAccount.pubkey) : [] if (memoryCachedPosts && memoryCachedPosts.length > 0) { setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev) } - const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey) + const memoryCachedHighlights = activeAccount ? getCachedHighlights(activeAccount.pubkey) : [] if (memoryCachedHighlights && memoryCachedHighlights.length > 0) { setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev) } @@ -282,10 +284,13 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) } + // At this point, we have seeded any available data; lift the loading state + setLoading(false) + // Fetch the user's contacts (friends) const contacts = await fetchContacts( relayPool, - activeAccount.pubkey, + activeAccount?.pubkey || '', (partial) => { // Store followed pubkeys for highlight classification setFollowedPubkeys(partial) @@ -333,7 +338,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return timeB - timeA }) }) - setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) + if (activeAccount) setCachedPosts(activeAccount.pubkey, upsertCachedPost(activeAccount.pubkey, post)) } ).then((all) => { setBlogPosts((prev) => { @@ -362,7 +367,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const timeB = b.published || b.event.created_at return timeB - timeA }) - setCachedPosts(activeAccount.pubkey, merged) + if (activeAccount) setCachedPosts(activeAccount.pubkey, merged) return merged }) }) @@ -378,14 +383,14 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const next = [...prev, highlight] return next.sort((a, b) => b.created_at - a.created_at) }) - setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight)) + if (activeAccount) setCachedHighlights(activeAccount.pubkey, upsertCachedHighlight(activeAccount.pubkey, highlight)) } ).then((all) => { setHighlights((prev) => { const byId = new Map(prev.map(h => [h.id, h])) for (const highlight of all) byId.set(highlight.id, highlight) const merged = Array.from(byId.values()).sort((a, b) => b.created_at - a.created_at) - setCachedHighlights(activeAccount.pubkey, merged) + if (activeAccount) setCachedHighlights(activeAccount.pubkey, merged) return merged }) }) @@ -403,7 +408,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const contactsArray = Array.from(contacts) // Use centralized writingsController for my posts (non-blocking) - const myPostsPromise: Promise = Promise.resolve(writingsController.getWritings()) + // pull from writingsController; no need to store promise + setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...writingsController.getWritings()]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))) setHasLoadedMine(true) const nostrversePostsPromise = visibility.nostrverse ? fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined, (post) => { @@ -428,45 +434,43 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti }) : Promise.resolve([] as BlogPostPreview[]) - const [myPosts, friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ - myPostsPromise, - fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), - fetchHighlightsFromAuthors(relayPool, contactsArray), - nostrversePostsPromise, - fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) - ]) + // Fire non-blocking fetches and merge as they resolve + fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls) + .then((friendsPosts) => { + setBlogPosts(prev => { + const merged = dedupeWritingsByReplaceable([...prev, ...friendsPosts]) + const sorted = merged.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) + if (activeAccount) setCachedPosts(activeAccount.pubkey, sorted) + // Pre-cache profiles in background + const authorPubkeys = Array.from(new Set(sorted.map(p => p.author))) + fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(() => {}) + return sorted + }) + }).catch(() => {}) - // Merge and deduplicate all posts - const allPosts = [...myPosts, ...friendsPosts, ...nostrversePosts] - const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => { - const timeA = a.published || a.event.created_at - const timeB = b.published || b.event.created_at - return timeB - timeA - }) + fetchHighlightsFromAuthors(relayPool, contactsArray) + .then((friendsHighlights) => { + setHighlights(prev => { + const merged = dedupeHighlightsById([...prev, ...friendsHighlights]) + const sorted = merged.sort((a, b) => b.created_at - a.created_at) + if (activeAccount) setCachedHighlights(activeAccount.pubkey, sorted) + return sorted + }) + }).catch(() => {}) - // Merge and deduplicate all highlights (mine from controller + friends + nostrverse) - const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] - const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at) + nostrversePostsPromise.then((nostrversePosts) => { + setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))) + }).catch(() => {}) - // Fetch profiles for all blog post authors to cache them - if (uniquePosts.length > 0) { - const authorPubkeys = Array.from(new Set(uniquePosts.map(p => p.author))) - fetchProfiles(relayPool, eventStore, authorPubkeys, settings).catch(err => { - console.error('Failed to fetch author profiles:', err) - }) - } - - // No blocking errors - let empty states handle messaging - setBlogPosts(uniquePosts) - setCachedPosts(activeAccount.pubkey, uniquePosts) - - setHighlights(uniqueHighlights) - setCachedHighlights(activeAccount.pubkey, uniqueHighlights) + fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) + .then((nostriverseHighlights) => { + setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at)) + }).catch(() => {}) } catch (err) { console.error('Failed to load data:', err) // No blocking error - user can pull-to-refresh } finally { - setLoading(false) + // loading is already turned off after seeding } } From 401d333e0fcb67ee6a6efd192d72fdb035b46110 Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:58:07 +0200 Subject: [PATCH 22/22] fix(explore): logged-out mode relies solely on centralized nostrverse controllers; start controllers even when logged out --- src/App.tsx | 8 +++++++ src/components/Explore.tsx | 49 ++------------------------------------ 2 files changed, 10 insertions(+), 47 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 7c7c59e5..57083fde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -128,6 +128,14 @@ function AppRoutes({ } }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) + // Ensure nostrverse controllers run even when logged out + useEffect(() => { + if (relayPool && eventStore) { + nostrverseHighlightsController.start({ relayPool, eventStore }) + nostrverseWritingsController.start({ relayPool, eventStore }) + } + }, [relayPool, eventStore]) + // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) { diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 7abffdce..a6abf245 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -204,53 +204,8 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // If not logged in, only fetch nostrverse content with streaming posts if (!activeAccount) { - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const highlightPromise = fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) - - // Stream posts as they arrive - const postsPromise = fetchNostrverseBlogPosts( - relayPool, - relayUrls, - 50, - eventStore || undefined, - (post) => { - setBlogPosts(prev => { - const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${post.author}:${dTag}` - const existingIndex = prev.findIndex(p => { - const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - return `${p.author}:${pDTag}` === key - }) - if (existingIndex >= 0) { - const existing = prev[existingIndex] - if (post.event.created_at <= existing.event.created_at) return prev - const next = [...prev] - next[existingIndex] = post - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - } - const next = [...prev, post] - return next.sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - } - ) - - // When each finishes, merge without blocking initial render - postsPromise.then((finalPosts) => { - setBlogPosts(prev => { - const byKey = new Map() - for (const p of [...prev, ...finalPosts]) { - const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' - const key = `${p.author}:${dTag}` - const existing = byKey.get(key) - if (!existing || p.event.created_at > existing.event.created_at) byKey.set(key, p) - } - return Array.from(byKey.values()).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at)) - }) - }).catch(() => {}) - highlightPromise.then((nostriverseHighlights) => { - setHighlights(prev => dedupeHighlightsById([...prev, ...nostriverseHighlights]).sort((a, b) => b.created_at - a.created_at)) - }).catch(() => {}) - // drop through; do not early return so post-login path runs seeding too + // Logged out: rely entirely on centralized controllers; do not fetch here + setLoading(false) } // Seed from in-memory cache if available to avoid empty flash