diff --git a/src/App.tsx b/src/App.tsx index ee0f1e95..57083fde 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -23,6 +23,10 @@ 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' +// 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' @@ -109,9 +113,29 @@ 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 }) + } + + // 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]) + // 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/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

diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 1b6069b6..a6abf245 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' @@ -27,6 +28,8 @@ 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' +import { nostrverseWritingsController } from '../services/nostrverseWritingsController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -48,10 +51,13 @@ 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) + const [hasLoadedMine, setHasLoadedMine] = useState(false) + const [hasLoadedNostrverseHighlights, setHasLoadedNostrverseHighlights] = useState(false) // 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, []) @@ -66,24 +72,123 @@ 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 }) + // 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) - const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) return () => { unsubHighlights() - unsubLoading() } }, []) + // 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 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 + 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 settings/login state changes + useEffect(() => { + 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 + setHasLoadedNostrverseHighlights(true) + } else { + // When logged in, use settings defaults immediately + setVisibility({ + nostrverse: settings?.defaultExploreScopeNostrverse ?? false, + friends: settings?.defaultExploreScopeFriends ?? true, + mine: settings?.defaultExploreScopeMine ?? false + }) + setHasLoadedNostrverse(false) + setHasLoadedNostrverseHighlights(false) + } + }, [activeAccount, settings?.defaultExploreScopeNostrverse, settings?.defaultExploreScopeFriends, settings?.defaultExploreScopeMine]) + // Update local state when prop changes useEffect(() => { if (propActiveTab) { @@ -93,21 +198,22 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti useEffect(() => { const loadData = async () => { - if (!activeAccount) { - setLoading(false) - return - } - 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 + if (!activeAccount) { + // Logged out: rely entirely on centralized controllers; do not fetch here + setLoading(false) + } + // 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) } @@ -133,10 +239,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) @@ -184,7 +293,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) => { @@ -213,7 +322,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 }) }) @@ -229,14 +338,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 }) }) @@ -250,52 +359,143 @@ 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 + mine content in parallel const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) const contactsArray = Array.from(contacts) - const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ - fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), - fetchHighlightsFromAuthors(relayPool, contactsArray), - fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), - fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) - ]) + // Use centralized writingsController for my posts (non-blocking) + // 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) => { + // 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[]) - // Merge and deduplicate all posts - const allPosts = [...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 - }) + // 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 highlights (mine from controller + friends + nostrverse) - const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] - const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at) + 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(() => {}) - // 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) - }) - } + nostrversePostsPromise.then((nostrversePosts) => { + setBlogPosts(prev => dedupeWritingsByReplaceable([...prev, ...nostrversePosts]).sort((a, b) => (b.published || b.event.created_at) - (a.published || a.event.created_at))) + }).catch(() => {}) - // 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 } } loadData() - }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings]) + // 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]) + + // 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(() => { + if (!activeAccount || !visibility.mine || hasLoadedMine) return + setHasLoadedMine(true) + }, [visibility.mine, activeAccount, hasLoadedMine]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -333,10 +533,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 @@ -360,7 +570,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) { @@ -403,7 +613,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } return classifiedHighlights.length === 0 ? (
- + No highlights to show for the selected scope.
) : (
@@ -422,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 (
@@ -451,7 +661,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" @@ -462,7 +672,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" @@ -474,7 +684,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" diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 70ff766b..c1af4ab6 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: () => { @@ -714,7 +752,7 @@ const Me: React.FC = ({
) } - return writings.length === 0 && !loading ? ( + return writings.length === 0 && !loading && !(isOwnProfile && myWritingsLoading) ? (
No articles written yet.
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'} 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, 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() + + diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index c1e98841..4374e9cf 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) + } } } } @@ -92,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 }, @@ -104,6 +120,7 @@ export const fetchNostrverseHighlights = async ( if (eventStore) { eventStore.add(event) } + collected.push(event) } } ) @@ -113,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') 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() + + 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() +