From e8e629f4e128abac2169e8ef587de7913ec9dbf1 Mon Sep 17 00:00:00 2001 From: Gigi Date: Mon, 20 Oct 2025 00:18:23 +0200 Subject: [PATCH] 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. --- src/components/Me.tsx | 93 +++--------- src/services/readingProgressController.ts | 11 +- src/services/readsController.ts | 176 ---------------------- 3 files changed, 30 insertions(+), 250 deletions(-) delete mode 100644 src/services/readsController.ts diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 2bd39324..098b5a91 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -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 = ({ 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() - 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 = ({ 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() - 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) diff --git a/src/services/readingProgressController.ts b/src/services/readingProgressController.ts index e5859139..6fbc97c0 100644 --- a/src/services/readingProgressController.ts +++ b/src/services/readingProgressController.ts @@ -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 ===') diff --git a/src/services/readsController.ts b/src/services/readsController.ts deleted file mode 100644 index aaaed1e8..00000000 --- a/src/services/readsController.ts +++ /dev/null @@ -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 { - 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() - - // 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()