mirror of
https://github.com/dergigi/boris.git
synced 2026-01-18 06:14:27 +01:00
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:
@@ -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(() => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
221
src/services/readingProgressController.ts
Normal file
221
src/services/readingProgressController.ts
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user