diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index 428feb54..07115dbd 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -30,10 +30,7 @@ import { useStoreTimeline } from '../hooks/useStoreTimeline' import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' import { writingsController } from '../services/writingsController' import { nostrverseWritingsController } from '../services/nostrverseWritingsController' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { ReadItem } from '../services/readsService' -import { RELAYS } from '../config/relays' +import { readingProgressController } from '../services/readingProgressController' const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers @@ -177,40 +174,32 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti return () => unsub() }, []) - // Load reading progress data + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + + // Load reading progress data when logged in useEffect(() => { if (!activeAccount?.pubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [activeAccount?.pubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: refreshTrigger > 0 + }) + }, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger]) // Update visibility when settings/login state changes useEffect(() => { diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 394f5068..9d709b89 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -31,10 +31,7 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { RELAYS } from '../config/relays' -import { KINDS } from '../config/kinds' +import { readingProgressController } from '../services/readingProgressController' interface MeProps { relayPool: RelayPool @@ -156,40 +153,32 @@ const Me: React.FC = ({ } } + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + // Load reading progress data for writings tab useEffect(() => { if (!viewingPubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [viewingPubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [viewingPubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: viewingPubkey, + force: refreshTrigger > 0 + }) + }, [viewingPubkey, relayPool, eventStore, refreshTrigger]) // Tab-specific loading functions const loadHighlightsTab = async () => { diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 9503c7cf..671ac89f 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -19,9 +19,7 @@ import { toBlogPostPreview } from '../utils/toBlogPostPreview' import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { Hooks } from 'applesauce-react' -import { queryEvents } from '../services/dataFetch' -import { processReadingProgress } from '../services/readingDataProcessor' -import { ReadItem } from '../services/readsService' +import { readingProgressController } from '../services/readingProgressController' interface ProfileProps { relayPool: RelayPool @@ -66,40 +64,32 @@ const Profile: React.FC = ({ } }, [propActiveTab]) - // Load reading progress data for logged-in user + // Subscribe to reading progress controller + useEffect(() => { + // Get initial state immediately + setReadingProgressMap(readingProgressController.getProgressMap()) + + // Subscribe to updates + const unsubProgress = readingProgressController.onProgress(setReadingProgressMap) + + return () => { + unsubProgress() + } + }, []) + + // Load reading progress data when logged in useEffect(() => { if (!activeAccount?.pubkey) { - setReadingProgressMap(new Map()) return } - const loadReadingProgress = async () => { - try { - const progressEvents = await queryEvents( - relayPool, - { kinds: [KINDS.ReadingProgress], authors: [activeAccount.pubkey] }, - { relayUrls: RELAYS } - ) - - const readsMap = new Map() - processReadingProgress(progressEvents, readsMap) - - // Convert to naddr -> progress map - const progressMap = new Map() - for (const [id, item] of readsMap.entries()) { - if (item.readingProgress !== undefined && item.type === 'article') { - progressMap.set(id, item.readingProgress) - } - } - - setReadingProgressMap(progressMap) - } catch (err) { - console.error('Failed to load reading progress:', err) - } - } - - loadReadingProgress() - }, [activeAccount?.pubkey, relayPool, refreshTrigger]) + readingProgressController.start({ + relayPool, + eventStore, + pubkey: activeAccount.pubkey, + force: refreshTrigger > 0 + }) + }, [activeAccount?.pubkey, relayPool, eventStore, refreshTrigger]) // Background fetch to populate event store (non-blocking) useEffect(() => { diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts new file mode 100644 index 00000000..495d374b --- /dev/null +++ b/src/services/readingProgressController.ts @@ -0,0 +1,221 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' +import { RELAYS } from '../config/relays' +import { processReadingProgress } from './readingDataProcessor' +import { ReadItem } from './readsService' + +type ProgressMapCallback = (progressMap: Map) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'reading_progress_last_synced' + +/** + * Shared reading progress controller + * Manages the user's reading progress (kind:39802) centrally + */ +class ReadingProgressController { + private progressListeners: ProgressMapCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentProgressMap: Map = new Map() + private lastLoadedPubkey: string | null = null + private generation = 0 + + onProgress(cb: ProgressMapCallback): () => void { + this.progressListeners.push(cb) + return () => { + this.progressListeners = this.progressListeners.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 emitProgress(progressMap: Map): void { + this.progressListeners.forEach(cb => cb(new Map(progressMap))) + } + + /** + * Get current reading progress map without triggering a reload + */ + getProgressMap(): Map { + return new Map(this.currentProgressMap) + } + + /** + * Get progress for a specific article by naddr + */ + getProgress(naddr: string): number | undefined { + return this.currentProgressMap.get(naddr) + } + + /** + * Check if reading progress is loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.generation++ + this.currentProgressMap = new Map() + this.lastLoadedPubkey = null + this.emitProgress(this.currentProgressMap) + } + + /** + * 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 updateLastSyncedAt(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('Failed to update last synced timestamp:', err) + } + } + + /** + * Load and watch reading progress for a user + */ + async start(params: { + relayPool: RelayPool + eventStore: IEventStore + pubkey: string + force?: boolean + }): Promise { + const { relayPool, eventStore, pubkey, force = false } = params + const startGeneration = this.generation + + // Skip if already loaded for this pubkey and not forcing + if (!force && this.isLoadedFor(pubkey)) { + console.log('📊 [ReadingProgress] Already loaded for', pubkey.slice(0, 8)) + return + } + + console.log('📊 [ReadingProgress] Loading for', pubkey.slice(0, 8), force ? '(forced)' : '') + + this.setLoading(true) + this.lastLoadedPubkey = pubkey + + try { + // Load from event store first for instant display + const cachedEvents = await eventStore.list([ + { kinds: [KINDS.ReadingProgress], authors: [pubkey] } + ]) + + if (startGeneration !== this.generation) { + console.log('📊 [ReadingProgress] Cancelled (generation changed)') + return + } + + if (cachedEvents.length > 0) { + this.processEvents(cachedEvents) + console.log('📊 [ReadingProgress] Loaded', cachedEvents.length, 'from cache') + } + + // Fetch from relays (incremental or full) + const lastSynced = force ? null : this.getLastSyncedAt(pubkey) + const filter: any = { + kinds: [KINDS.ReadingProgress], + authors: [pubkey] + } + + if (lastSynced && !force) { + filter.since = lastSynced + console.log('📊 [ReadingProgress] Incremental sync since', new Date(lastSynced * 1000).toISOString()) + } + + const events = await queryEvents(relayPool, filter, { relayUrls: RELAYS }) + + if (startGeneration !== this.generation) { + console.log('📊 [ReadingProgress] Cancelled (generation changed)') + return + } + + if (events.length > 0) { + // Add to event store + events.forEach(e => eventStore.add(e)) + + // Process and emit + this.processEvents(events) + console.log('📊 [ReadingProgress] Loaded', events.length, 'from relays') + + // Update last synced + const now = Math.floor(Date.now() / 1000) + this.updateLastSyncedAt(pubkey, now) + } else { + console.log('📊 [ReadingProgress] No new progress events') + } + } catch (err) { + console.error('📊 [ReadingProgress] Failed to load:', err) + } finally { + if (startGeneration === this.generation) { + this.setLoading(false) + } + } + } + + /** + * Process events and update progress map + */ + private processEvents(events: any[]): void { + const readsMap = new Map() + + // Merge with existing progress + for (const [id, progress] of this.currentProgressMap.entries()) { + readsMap.set(id, { + id, + source: 'reading-progress', + type: 'article', + readingProgress: progress + }) + } + + // Process new events + processReadingProgress(events, readsMap) + + // Convert back to progress map (naddr -> progress) + const newProgressMap = new Map() + for (const [id, item] of readsMap.entries()) { + if (item.readingProgress !== undefined && item.type === 'article') { + newProgressMap.set(id, item.readingProgress) + } + } + + this.currentProgressMap = newProgressMap + this.emitProgress(this.currentProgressMap) + } +} + +export const readingProgressController = new ReadingProgressController() +