mirror of
https://github.com/dergigi/boris.git
synced 2026-01-18 06:14:27 +01:00
feat(nostrverse): add nostrverseWritingsController and subscribe in Explore; start controller at app init
This commit is contained in:
@@ -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])
|
||||
|
||||
@@ -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<ExploreProps> = ({ 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<string, BlogPostPreview>()
|
||||
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
|
||||
|
||||
169
src/services/nostrverseWritingsController.ts
Normal file
169
src/services/nostrverseWritingsController.ts
Normal file
@@ -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<void> {
|
||||
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<string>()
|
||||
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user