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() +