mirror of
https://github.com/dergigi/boris.git
synced 2026-01-18 06:14:27 +01:00
246 lines
7.1 KiB
TypeScript
246 lines
7.1 KiB
TypeScript
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<void> {
|
|
const { relayPool, eventStore, pubkey, force = false } = options
|
|
|
|
// Skip if already loaded for this pubkey (unless forced)
|
|
if (!force && this.isLoadedFor(pubkey)) {
|
|
this.emitWritings(this.currentPosts)
|
|
return
|
|
}
|
|
|
|
// Increment generation to cancel any in-flight work
|
|
this.generation++
|
|
const currentGeneration = this.generation
|
|
|
|
this.setLoading(true)
|
|
|
|
try {
|
|
const seenIds = new Set<string>()
|
|
const uniqueByReplaceable = new Map<string, BlogPostPreview>()
|
|
|
|
// 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
|
|
}
|
|
|
|
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) {
|
|
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)
|
|
}
|
|
|
|
} 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()
|
|
|