diff --git a/src/App.tsx b/src/App.tsx index 329a19fa..03382256 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,8 +20,10 @@ import { RELAYS } from './config/relays' import { SkeletonThemeProvider } from './components/Skeletons' import { DebugBus } from './utils/debugBus' import { Bookmark } from './types/bookmarks' +import { Highlight } from './types/highlights' import { bookmarkController } from './services/bookmarkController' import { contactsController } from './services/contactsController' +import { highlightsController } from './services/highlightsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -29,9 +31,11 @@ const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || // AppRoutes component that has access to hooks function AppRoutes({ relayPool, + eventStore, showToast }: { relayPool: RelayPool + eventStore: EventStore | null showToast: (message: string) => void }) { const accountManager = Hooks.useAccountManager() @@ -45,6 +49,10 @@ function AppRoutes({ const [contacts, setContacts] = useState>(new Set()) const [contactsLoading, setContactsLoading] = useState(false) + // Centralized highlights state (fed by controller) + const [highlights, setHighlights] = useState([]) + const [highlightsLoading, setHighlightsLoading] = useState(false) + // Subscribe to bookmark controller useEffect(() => { console.log('[bookmark] 🎧 Subscribing to bookmark controller') @@ -83,7 +91,26 @@ function AppRoutes({ } }, []) - // Auto-load bookmarks and contacts when account is ready (on login or page mount) + // Subscribe to highlights controller + useEffect(() => { + console.log('[highlights] 🎧 Subscribing to highlights controller') + const unsubHighlights = highlightsController.onHighlights((highlights) => { + console.log('[highlights] 📥 Received highlights:', highlights.length) + setHighlights(highlights) + }) + const unsubLoading = highlightsController.onLoading((loading) => { + console.log('[highlights] 📥 Loading state:', loading) + setHighlightsLoading(loading) + }) + + return () => { + console.log('[highlights] 🔇 Unsubscribing from highlights controller') + unsubHighlights() + unsubLoading() + } + }, []) + + // Auto-load bookmarks, contacts, and highlights when account is ready (on login or page mount) useEffect(() => { if (activeAccount && relayPool) { const pubkey = (activeAccount as { pubkey?: string }).pubkey @@ -99,8 +126,14 @@ function AppRoutes({ console.log('[contacts] 🚀 Auto-loading contacts on mount/login') contactsController.start({ relayPool, pubkey }) } + + // Load highlights + if (pubkey && eventStore && highlights.length === 0 && !highlightsLoading) { + console.log('[highlights] 🚀 Auto-loading highlights on mount/login') + highlightsController.start({ relayPool, eventStore, pubkey }) + } } - }, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) + }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, highlights.length, highlightsLoading, accountManager]) // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { @@ -117,6 +150,7 @@ function AppRoutes({ accountManager.clearActive() bookmarkController.reset() // Clear bookmarks via controller contactsController.reset() // Clear contacts via controller + highlightsController.reset() // Clear highlights via controller showToast('Logged out successfully') } @@ -299,6 +333,7 @@ function AppRoutes({ element={
- +
diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 920d261d..cfde73b3 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -3,11 +3,10 @@ import { useNavigate } from 'react-router-dom' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faClock, faSpinner } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' -import { useEventStore } from 'applesauce-react/hooks' import { Accounts } from 'applesauce-accounts' import { NostrConnectSigner } from 'applesauce-signers' import { RelayPool } from 'applesauce-relay' -import { Helpers } from 'applesauce-core' +import { Helpers, IEventStore } from 'applesauce-core' import { nip19 } from 'nostr-tools' import { getDefaultBunkerPermissions } from '../services/nostrConnect' import { DebugBus, type DebugLogEntry } from '../utils/debugBus' @@ -24,6 +23,7 @@ const defaultPayload = 'The quick brown fox jumps over the lazy dog.' interface DebugProps { relayPool: RelayPool | null + eventStore: IEventStore | null bookmarks: Bookmark[] bookmarksLoading: boolean onRefreshBookmarks: () => Promise @@ -32,6 +32,7 @@ interface DebugProps { const Debug: React.FC = ({ relayPool, + eventStore, bookmarks, bookmarksLoading, onRefreshBookmarks, @@ -40,11 +41,10 @@ const Debug: React.FC = ({ const navigate = useNavigate() const activeAccount = Hooks.useActiveAccount() const accountManager = Hooks.useAccountManager() - const eventStore = useEventStore() const { settings, saveSettings } = useSettings({ relayPool, - eventStore, + eventStore: eventStore!, pubkey: activeAccount?.pubkey, accountManager }) @@ -450,7 +450,7 @@ const Debug: React.FC = ({ const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] return next.sort((a, b) => b.created_at - a.created_at) }) - }, settings) + }, settings, false, eventStore || undefined) } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) @@ -492,7 +492,7 @@ const Debug: React.FC = ({ const next = [...prev, { ...h, pubkey: h.pubkey, created_at: h.created_at, id: h.id, kind: 9802, tags: [], content: h.content, sig: '' } as NostrEvent] return next.sort((a, b) => b.created_at - a.created_at) }) - }) + }, eventStore || undefined) } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 0e74dd74..ce44fba5 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -9,6 +9,7 @@ import { useNavigate, useParams } from 'react-router-dom' import { Highlight } from '../types/highlights' import { HighlightItem } from './HighlightItem' import { fetchHighlights } from '../services/highlightService' +import { highlightsController } from '../services/highlightsController' import { fetchAllReads, ReadItem } from '../services/readsService' import { fetchLinks } from '../services/linksService' import { BlogPostPreview, fetchBlogPostsFromAuthors } from '../services/exploreService' @@ -123,8 +124,17 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) - const userHighlights = await fetchHighlights(relayPool, viewingPubkey) - setHighlights(userHighlights) + + // For own profile, prefer controller (already loaded on app start) + if (isOwnProfile) { + const userHighlights = highlightsController.getHighlights() + setHighlights(userHighlights) + } else { + // For viewing other users, fetch on-demand + const userHighlights = await fetchHighlights(relayPool, viewingPubkey) + setHighlights(userHighlights) + } + setLoadedTabs(prev => new Set(prev).add('highlights')) } catch (err) { console.error('Failed to load highlights:', err) diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index ae18eccd..5bdc6d56 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -1,5 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' @@ -13,7 +14,8 @@ export const fetchHighlights = async ( pubkey: string, onHighlight?: (highlight: Highlight) => void, settings?: UserSettings, - force = false + force = false, + eventStore?: IEventStore ): Promise => { // Check cache first unless force refresh if (!force) { @@ -37,6 +39,12 @@ export const fetchHighlights = async ( onEvent: (event: NostrEvent) => { if (seenIds.has(event.id)) return seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + if (onHighlight) onHighlight(eventToHighlight(event)) } } @@ -44,6 +52,11 @@ export const fetchHighlights = async ( console.log(`📌 Fetched ${rawEvents.length} highlight events for author:`, pubkey.slice(0, 8)) + // Store all events in event store if provided + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + try { await rebroadcastEvents(rawEvents, relayPool, settings) } catch (err) { diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index 90c14c65..e95dde2f 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -1,5 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' @@ -14,7 +15,8 @@ export const fetchHighlightsForArticle = async ( eventId?: string, onHighlight?: (highlight: Highlight) => void, settings?: UserSettings, - force = false + force = false, + eventStore?: IEventStore ): Promise => { // Check cache first unless force refresh if (!force) { @@ -34,6 +36,12 @@ export const fetchHighlightsForArticle = async ( const onEvent = (event: NostrEvent) => { if (seenIds.has(event.id)) return seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + if (onHighlight) onHighlight(eventToHighlight(event)) } @@ -48,6 +56,11 @@ export const fetchHighlightsForArticle = async ( const rawEvents = [...aTagEvents, ...eTagEvents] console.log(`📌 Fetched ${rawEvents.length} highlight events for article:`, articleCoordinate) + // Store all events in event store if provided + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + try { await rebroadcastEvents(rawEvents, relayPool, settings) } catch (err) { diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index c1e569f0..90e4eff5 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -1,5 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' @@ -13,7 +14,8 @@ export const fetchHighlightsForUrl = async ( url: string, onHighlight?: (highlight: Highlight) => void, settings?: UserSettings, - force = false + force = false, + eventStore?: IEventStore ): Promise => { // Check cache first unless force refresh if (!force) { @@ -37,6 +39,12 @@ export const fetchHighlightsForUrl = async ( onEvent: (event: NostrEvent) => { if (seenIds.has(event.id)) return seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + if (onHighlight) onHighlight(eventToHighlight(event)) } } @@ -44,6 +52,11 @@ export const fetchHighlightsForUrl = async ( console.log(`📌 Fetched ${rawEvents.length} highlight events for URL:`, url) + // Store all events in event store if provided + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + // Rebroadcast events - but don't let errors here break the highlight display try { await rebroadcastEvents(rawEvents, relayPool, settings) diff --git a/src/services/highlights/fetchFromAuthors.ts b/src/services/highlights/fetchFromAuthors.ts index b157be5b..5e258824 100644 --- a/src/services/highlights/fetchFromAuthors.ts +++ b/src/services/highlights/fetchFromAuthors.ts @@ -1,5 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { queryEvents } from '../dataFetch' @@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch' * @param relayPool - The relay pool to query * @param pubkeys - Array of pubkeys to fetch highlights from * @param onHighlight - Optional callback for streaming highlights as they arrive + * @param eventStore - Optional event store to persist events * @returns Array of highlights */ export const fetchHighlightsFromAuthors = async ( relayPool: RelayPool, pubkeys: string[], - onHighlight?: (highlight: Highlight) => void + onHighlight?: (highlight: Highlight) => void, + eventStore?: IEventStore ): Promise => { try { if (pubkeys.length === 0) { @@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async ( onEvent: (event: NostrEvent) => { if (!seenIds.has(event.id)) { seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + if (onHighlight) onHighlight(eventToHighlight(event)) } } } ) + // Store all events in event store if provided + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) diff --git a/src/services/highlightsController.ts b/src/services/highlightsController.ts new file mode 100644 index 00000000..d9d2181f --- /dev/null +++ b/src/services/highlightsController.ts @@ -0,0 +1,208 @@ +import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' +import { Highlight } from '../types/highlights' +import { queryEvents } from './dataFetch' +import { KINDS } from '../config/kinds' +import { eventToHighlight, sortHighlights } from './highlightEventProcessor' + +type HighlightsCallback = (highlights: Highlight[]) => void +type LoadingCallback = (loading: boolean) => void + +const LAST_SYNCED_KEY = 'highlights_last_synced' + +/** + * Shared highlights controller + * Manages the user's highlights centrally, similar to bookmarkController + */ +class HighlightsController { + private highlightsListeners: HighlightsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentHighlights: Highlight[] = [] + private lastLoadedPubkey: string | null = null + private generation = 0 + + onHighlights(cb: HighlightsCallback): () => void { + this.highlightsListeners.push(cb) + return () => { + this.highlightsListeners = this.highlightsListeners.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 emitHighlights(highlights: Highlight[]): void { + this.highlightsListeners.forEach(cb => cb(highlights)) + } + + /** + * Get current highlights without triggering a reload + */ + getHighlights(): Highlight[] { + return [...this.currentHighlights] + } + + /** + * Check if highlights are loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0 + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.generation++ + this.currentHighlights = [] + this.lastLoadedPubkey = null + this.emitHighlights(this.currentHighlights) + } + + /** + * 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('[highlights] Failed to save last synced timestamp:', err) + } + } + + /** + * Load highlights for a user + * Streams results and stores in event store + */ + async start(options: { + relayPool: RelayPool + eventStore: IEventStore + pubkey: string + force?: boolean + }): Promise { + const { relayPool, eventStore, pubkey, force = false } = options + + // Skip if already loaded for this pubkey (unless forced) + if (!force && this.isLoadedFor(pubkey)) { + console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8)) + this.emitHighlights(this.currentHighlights) + return + } + + // Increment generation to cancel any in-flight work + this.generation++ + const currentGeneration = this.generation + + this.setLoading(true) + console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8)) + + try { + const seenIds = new Set() + const highlightsMap = new Map() + + // Get last synced timestamp for incremental loading + const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey) + const filter: { kinds: number[]; authors: string[]; since?: number } = { + kinds: [KINDS.Highlights], + authors: [pubkey] + } + if (lastSyncedAt) { + filter.since = lastSyncedAt + console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString()) + } + + const events = await queryEvents( + relayPool, + filter, + { + onEvent: (evt) => { + // Check if this generation is still active + if (currentGeneration !== this.generation) return + + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + // Store in event store immediately + eventStore.add(evt) + + // Convert to highlight and add to map + const highlight = eventToHighlight(evt) + highlightsMap.set(highlight.id, highlight) + + // Stream to listeners + const sortedHighlights = sortHighlights(Array.from(highlightsMap.values())) + this.currentHighlights = sortedHighlights + this.emitHighlights(sortedHighlights) + } + } + ) + + // Check if still active after async operation + if (currentGeneration !== this.generation) { + console.log('[highlights] ⚠️ Load cancelled (generation mismatch)') + return + } + + // Store all events in event store + events.forEach(evt => eventStore.add(evt)) + + // Final processing + const highlights = events.map(eventToHighlight) + const uniqueHighlights = Array.from( + new Map(highlights.map(h => [h.id, h])).values() + ) + const sorted = sortHighlights(uniqueHighlights) + + this.currentHighlights = sorted + this.lastLoadedPubkey = pubkey + this.emitHighlights(sorted) + + // Update last synced timestamp + if (sorted.length > 0) { + const newestTimestamp = Math.max(...sorted.map(h => h.created_at)) + this.setLastSyncedAt(pubkey, newestTimestamp) + } + + console.log('[highlights] ✅ Loaded', sorted.length, 'highlights') + } catch (error) { + console.error('[highlights] ❌ Failed to load highlights:', error) + this.currentHighlights = [] + this.emitHighlights(this.currentHighlights) + } finally { + // Only clear loading if this generation is still active + if (currentGeneration === this.generation) { + this.setLoading(false) + } + } + } +} + +// Singleton instance +export const highlightsController = new HighlightsController() +