mirror of
https://github.com/dergigi/boris.git
synced 2026-01-17 05:44:24 +01:00
fix: prevent concurrent start() calls in readingProgressController
Added isLoading flag to block multiple start() calls from running in parallel. The repeated start() calls were all waiting on queryEvents() calls, creating a thundering herd that prevented any from completing. Now only one start() runs at a time, and concurrent calls are skipped with a console log.
This commit is contained in:
@@ -30,16 +30,13 @@ import ReadingProgressFilters, { ReadingProgressFilterType } from './ReadingProg
|
||||
import { filterByReadingProgress } from '../utils/readingProgressUtils'
|
||||
import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks'
|
||||
import { readingProgressController } from '../services/readingProgressController'
|
||||
import { queryEvents } from '../services/dataFetch'
|
||||
import { KINDS } from '../config/kinds'
|
||||
import { MARK_AS_READ_EMOJI } from '../services/reactionService'
|
||||
import { RELAYS } from '../config/relays'
|
||||
|
||||
interface MeProps {
|
||||
relayPool: RelayPool
|
||||
eventStore: IEventStore
|
||||
activeTab?: TabType
|
||||
bookmarks: Bookmark[] // From centralized App.tsx state (reserved for future use)
|
||||
bookmarks: Bookmark[] // From centralized App.tsx state
|
||||
bookmarksLoading?: boolean // From centralized App.tsx state (reserved for future use)
|
||||
}
|
||||
|
||||
type TabType = 'highlights' | 'reading-list' | 'reads' | 'links' | 'writings'
|
||||
@@ -276,43 +273,22 @@ const Me: React.FC<MeProps> = ({
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
}))
|
||||
|
||||
// Also load MARK_AS_READ for Nostr-native articles (kind:7)
|
||||
try {
|
||||
const reactions7 = await queryEvents(relayPool, { kinds: [7], authors: [viewingPubkey] }, { relayUrls: RELAYS })
|
||||
const markReactions = reactions7.filter(r => r.content === MARK_AS_READ_EMOJI)
|
||||
const eIdToTs = new Map<string, number>()
|
||||
const eIds: string[] = []
|
||||
markReactions.forEach(r => {
|
||||
const eId = r.tags.find(t => t[0] === 'e')?.[1]
|
||||
if (eId) {
|
||||
eIdToTs.set(eId, Math.max(eIdToTs.get(eId) || 0, r.created_at))
|
||||
eIds.push(eId)
|
||||
}
|
||||
})
|
||||
const uniqueEIds = Array.from(new Set(eIds))
|
||||
if (uniqueEIds.length > 0) {
|
||||
const articles = await queryEvents(relayPool, { kinds: [KINDS.BlogPost], ids: uniqueEIds }, { relayUrls: RELAYS })
|
||||
for (const article of articles) {
|
||||
const dTag = article.tags.find(t => t[0] === 'd')?.[1]
|
||||
if (!dTag) continue
|
||||
try {
|
||||
const naddr = nip19.naddrEncode({ kind: KINDS.BlogPost, pubkey: article.pubkey, identifier: dTag })
|
||||
if (!readItems.find(i => i.id === naddr)) {
|
||||
readItems.push({
|
||||
id: naddr,
|
||||
source: 'marked-as-read',
|
||||
type: 'article',
|
||||
markedAsRead: true,
|
||||
readingTimestamp: eIdToTs.get(article.id) || Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
// Include items that are only marked-as-read (no progress event yet)
|
||||
const markedIds = readingProgressController.getMarkedAsReadIds()
|
||||
for (const id of markedIds) {
|
||||
if (!readItems.find(i => i.id === id)) {
|
||||
const isArticle = id.startsWith('naddr1')
|
||||
readItems.push({
|
||||
id,
|
||||
source: 'marked-as-read',
|
||||
type: isArticle ? 'article' : 'external',
|
||||
url: isArticle ? undefined : id,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: Math.floor(Date.now() / 1000)
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed loading mark-as-read articles:', err)
|
||||
}
|
||||
|
||||
|
||||
const readsMap = new Map(readItems.map(item => [item.id, item]))
|
||||
setReadsMap(readsMap)
|
||||
setReads(readItems)
|
||||
@@ -361,45 +337,16 @@ const Me: React.FC<MeProps> = ({
|
||||
const initialMap = new Map(initialLinks.map(item => [item.id, item]))
|
||||
setLinksMap(initialMap)
|
||||
setLinks(initialLinks)
|
||||
|
||||
// Also load MARK_AS_READ for URLs (kind:17)
|
||||
try {
|
||||
const reactions17 = await queryEvents(relayPool, { kinds: [17], authors: [viewingPubkey] }, { relayUrls: RELAYS })
|
||||
const urlToTs = new Map<string, number>()
|
||||
reactions17.forEach(r => {
|
||||
if (r.content === MARK_AS_READ_EMOJI) {
|
||||
const rTag = r.tags.find(t => t[0] === 'r')?.[1]
|
||||
if (rTag) {
|
||||
urlToTs.set(rTag, Math.max(urlToTs.get(rTag) || 0, r.created_at))
|
||||
}
|
||||
}
|
||||
})
|
||||
const markedLinks: ReadItem[] = Array.from(urlToTs.entries()).map(([url, ts]) => ({
|
||||
id: url,
|
||||
source: 'marked-as-read',
|
||||
type: 'external',
|
||||
url,
|
||||
markedAsRead: true,
|
||||
readingTimestamp: ts
|
||||
}))
|
||||
// Merge these into links, even if not present from bookmarks
|
||||
const merged = new Map(initialMap)
|
||||
markedLinks.forEach(item => {
|
||||
merged.set(item.id, { ...(merged.get(item.id) || {} as ReadItem), ...item })
|
||||
})
|
||||
setLinksMap(merged)
|
||||
setLinks(Array.from(merged.values()))
|
||||
} catch (err) {
|
||||
console.warn('Failed loading mark-as-read links:', err)
|
||||
}
|
||||
|
||||
setLoadedTabs(prev => new Set(prev).add('links'))
|
||||
if (!hasBeenLoaded) setLoading(false)
|
||||
|
||||
// Background enrichment: merge reading progress and mark-as-read for items we already have
|
||||
// Background enrichment: merge reading progress and mark-as-read
|
||||
// Only update items that are already in our map
|
||||
fetchLinks(relayPool, viewingPubkey, (item) => {
|
||||
setLinksMap(prevMap => {
|
||||
// Only update if item exists in our current map
|
||||
if (!prevMap.has(item.id)) return prevMap
|
||||
|
||||
const newMap = new Map(prevMap)
|
||||
if (item.type === 'article' && item.author) {
|
||||
const progress = readingProgressMap.get(item.id)
|
||||
|
||||
@@ -30,6 +30,7 @@ class ReadingProgressController {
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
private timelineSubscription: { unsubscribe: () => void } | null = null
|
||||
private isLoading = false
|
||||
|
||||
onProgress(cb: ProgressMapCallback): () => void {
|
||||
this.progressListeners.push(cb)
|
||||
@@ -185,7 +186,14 @@ class ReadingProgressController {
|
||||
return
|
||||
}
|
||||
|
||||
// Prevent concurrent starts
|
||||
if (this.isLoading) {
|
||||
console.log('[readingProgress] Already loading, skipping concurrent start')
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
this.isLoading = true
|
||||
|
||||
try {
|
||||
// Seed from local cache immediately (survives refresh/flight mode)
|
||||
@@ -261,7 +269,7 @@ class ReadingProgressController {
|
||||
}
|
||||
|
||||
// Process mark-as-read reactions
|
||||
;[...kind17Events].forEach((evt) => {
|
||||
[...kind17Events].forEach((evt) => {
|
||||
if (evt.content === MARK_AS_READ_EMOJI) {
|
||||
// For kind:17, the URL is in the #r tag
|
||||
const rTag = evt.tags.find(t => t[0] === 'r')?.[1]
|
||||
@@ -340,6 +348,7 @@ class ReadingProgressController {
|
||||
} finally {
|
||||
if (startGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
this.isLoading = false
|
||||
}
|
||||
// Debug: Show what we have
|
||||
console.log('[readingProgress] === FINAL STATE ===')
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { fetchAllReads, ReadItem } from './readsService'
|
||||
import { mergeReadItem } from '../utils/readItemMerge'
|
||||
|
||||
type ReadsCallback = (reads: ReadItem[]) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
const LAST_SYNCED_KEY = 'reads_last_synced'
|
||||
|
||||
/**
|
||||
* Shared reads controller
|
||||
* Manages the user's reading activity centrally:
|
||||
* - Reading progress (kind:39802)
|
||||
* - Marked as read reactions (kind:7, kind:17)
|
||||
* - Highlights
|
||||
* - Bookmarked articles
|
||||
*
|
||||
* Streams updates as data arrives, similar to highlightsController
|
||||
*/
|
||||
class ReadsController {
|
||||
private readsListeners: ReadsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentReads: ReadItem[] = []
|
||||
private lastLoadedPubkey: string | null = null
|
||||
private generation = 0
|
||||
|
||||
onReads(cb: ReadsCallback): () => void {
|
||||
this.readsListeners.push(cb)
|
||||
return () => {
|
||||
this.readsListeners = this.readsListeners.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 emitReads(reads: ReadItem[]): void {
|
||||
this.readsListeners.forEach(cb => cb([...reads]))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current reads without triggering a reload
|
||||
*/
|
||||
getReads(): ReadItem[] {
|
||||
return [...this.currentReads]
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if reads are loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey && this.currentReads.length >= 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.generation++
|
||||
this.currentReads = []
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitReads(this.currentReads)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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('[reads] Failed to save last synced timestamp:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load reads for a user
|
||||
* Streams results as they arrive from relays
|
||||
*/
|
||||
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 (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
this.emitReads(this.currentReads)
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
|
||||
try {
|
||||
const readsMap = new Map<string, ReadItem>()
|
||||
|
||||
// Stream items as they're fetched
|
||||
// This updates the UI progressively as reading progress, marks as read, bookmarks arrive
|
||||
await fetchAllReads(relayPool, pubkey, [], (item) => {
|
||||
// Check if this generation is still active (user didn't log out)
|
||||
if (startGeneration !== this.generation) return
|
||||
|
||||
// Merge and update internal state
|
||||
mergeReadItem(readsMap, item)
|
||||
|
||||
// Sort and emit to listeners
|
||||
const sorted = Array.from(readsMap.values()).sort((a, b) => {
|
||||
const timeA = a.readingTimestamp || a.markedAt || 0
|
||||
const timeB = b.readingTimestamp || b.markedAt || 0
|
||||
return timeB - timeA
|
||||
})
|
||||
|
||||
this.currentReads = sorted
|
||||
this.emitReads(sorted)
|
||||
})
|
||||
|
||||
// Check if still active after async operation
|
||||
if (startGeneration !== this.generation) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update last synced timestamp
|
||||
const newestTimestamp = Math.max(
|
||||
...Array.from(readsMap.values()).map(r => r.readingTimestamp || r.markedAt || 0)
|
||||
)
|
||||
if (newestTimestamp > 0) {
|
||||
this.setLastSyncedAt(pubkey, Math.floor(newestTimestamp))
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('[reads] Failed to load reads:', error)
|
||||
this.currentReads = []
|
||||
this.emitReads(this.currentReads)
|
||||
} finally {
|
||||
// Only clear loading if this generation is still active
|
||||
if (startGeneration === this.generation) {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const readsController = new ReadsController()
|
||||
Reference in New Issue
Block a user