From 35efdb6d3fcd0fafd9fb65887496089dd237572f Mon Sep 17 00:00:00 2001 From: Gigi Date: Sun, 19 Oct 2025 00:52:32 +0200 Subject: [PATCH] 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() + +