refactor: create centralized reading progress controller

- Add readingProgressController following the same pattern as highlightsController and writingsController
- Controller manages reading progress (kind:39802) centrally with subscriptions
- Remove duplicated reading progress loading logic from Explore, Profile, and Me components
- Components now subscribe to controller updates instead of loading data individually
- Supports incremental sync and force reload
- Improves efficiency and maintainability
This commit is contained in:
Gigi
2025-10-19 11:06:57 +02:00
parent 80b26abff2
commit 5fd8976097
4 changed files with 286 additions and 97 deletions

View File

@@ -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<ExploreProps> = ({ 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<string, ReadItem>()
processReadingProgress(progressEvents, readsMap)
// Convert to naddr -> progress map
const progressMap = new Map<string, number>()
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(() => {

View File

@@ -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<MeProps> = ({
}
}
// 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<string, ReadItem>()
processReadingProgress(progressEvents, readsMap)
// Convert to naddr -> progress map
const progressMap = new Map<string, number>()
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 () => {

View File

@@ -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<ProfileProps> = ({
}
}, [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<string, ReadItem>()
processReadingProgress(progressEvents, readsMap)
// Convert to naddr -> progress map
const progressMap = new Map<string, number>()
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(() => {

View File

@@ -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<string, number>) => 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<string, number> = 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<string, number>): void {
this.progressListeners.forEach(cb => cb(new Map(progressMap)))
}
/**
* Get current reading progress map without triggering a reload
*/
getProgressMap(): Map<string, number> {
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<void> {
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<string, ReadItem>()
// 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<string, number>()
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()