diff --git a/src/App.tsx b/src/App.tsx index dd1e2c6c..ec689147 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import { SkeletonThemeProvider } from './components/Skeletons' import { DebugBus } from './utils/debugBus' import { Bookmark } from './types/bookmarks' import { bookmarkController } from './services/bookmarkController' +import { contactsController } from './services/contactsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -40,6 +41,10 @@ function AppRoutes({ const [bookmarks, setBookmarks] = useState([]) const [bookmarksLoading, setBookmarksLoading] = useState(false) + // Centralized contacts state (fed by controller) + const [contacts, setContacts] = useState>(new Set()) + const [contactsLoading, setContactsLoading] = useState(false) + // Subscribe to bookmark controller useEffect(() => { console.log('[bookmark] 🎧 Subscribing to bookmark controller') @@ -59,6 +64,25 @@ function AppRoutes({ } }, []) + // Subscribe to contacts controller + useEffect(() => { + console.log('[contacts] 🎧 Subscribing to contacts controller') + const unsubContacts = contactsController.onContacts((contacts) => { + console.log('[contacts] 📥 Received contacts:', contacts.size) + setContacts(contacts) + }) + const unsubLoading = contactsController.onLoading((loading) => { + console.log('[contacts] 📥 Loading state:', loading) + setContactsLoading(loading) + }) + + return () => { + console.log('[contacts] 🔇 Unsubscribing from contacts controller') + unsubContacts() + unsubLoading() + } + }, []) + // Auto-load bookmarks when account is ready (on login or page mount) useEffect(() => { if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) { @@ -67,6 +91,17 @@ function AppRoutes({ } }, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, accountManager]) + // Auto-load contacts when account is ready (on login or page mount) + useEffect(() => { + if (activeAccount && relayPool && contacts.size === 0 && !contactsLoading) { + const pubkey = (activeAccount as { pubkey?: string }).pubkey + if (pubkey) { + console.log('[contacts] 🚀 Auto-loading contacts on mount/login') + contactsController.start({ relayPool, pubkey }) + } + } + }, [activeAccount, relayPool, contacts.size, contactsLoading]) + // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { if (!relayPool || !activeAccount) { @@ -81,6 +116,7 @@ function AppRoutes({ const handleLogout = () => { accountManager.clearActive() bookmarkController.reset() // Clear bookmarks via controller + contactsController.reset() // Clear contacts via controller showToast('Logged out successfully') } diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index 7887a5e9..43ea5b78 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -18,7 +18,7 @@ import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' -import { fetchContacts } from '../services/contactService' +import { contactsController } from '../services/contactsController' const defaultPayload = 'The quick brown fox jumps over the lazy dog.' @@ -111,6 +111,12 @@ const Debug: React.FC = ({ return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) }, []) + // Subscribe to contacts controller for friends list display + useEffect(() => { + const unsubLoading = contactsController.onLoading(setFriendsLoading) + return unsubLoading + }, []) + // Live timer effect - triggers re-renders for live timing updates useEffect(() => { const interval = setInterval(() => { @@ -464,63 +470,40 @@ const Debug: React.FC = ({ DebugBus.warn('debug', 'Please log in to load friends highlights') return } + + // Ensure contacts are loaded first (will use cache if already loaded) + if (!contactsController.isLoadedFor(activeAccount.pubkey)) { + DebugBus.info('debug', 'Loading contacts first...') + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey }) + } + + const contacts = contactsController.getContacts() + if (contacts.size === 0) { + DebugBus.warn('debug', 'No friends found') + return + } + const start = performance.now() setHighlightEvents([]) setIsLoadingHighlights(true) setTLoadHighlights(null) setTFirstHighlight(null) - DebugBus.info('debug', 'Loading friends highlights (non-blocking)...') + DebugBus.info('debug', `Loading highlights from ${contacts.size} friends...`) let firstEventTime: number | null = null - const seenAuthors = new Set() try { - const contacts = await fetchContacts( - relayPool, - activeAccount.pubkey, - (partial) => { - // Non-blocking: start fetching as soon as we get partial contacts - if (partial.size > 0) { - const partialArray = Array.from(partial).filter(pk => !seenAuthors.has(pk)) - if (partialArray.length > 0) { - partialArray.forEach(pk => seenAuthors.add(pk)) - DebugBus.info('debug', `Fetching highlights from ${partialArray.length} friends (${seenAuthors.size} total)`) - - // Fire and forget - don't await - fetchHighlightsFromAuthors(relayPool, partialArray, (h) => { - if (firstEventTime === null) { - firstEventTime = performance.now() - start - setTFirstHighlight(Math.round(firstEventTime)) - } - setHighlightEvents(prev => { - if (prev.some(x => x.id === h.id)) return prev - 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) - }) - }).catch(err => console.error('Error fetching highlights from partial:', err)) - } - } + await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (h) => { + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) } - ) - - DebugBus.info('debug', `Found ${contacts.size} total friends`) - - // Fetch any remaining authors not covered by partials - const finalAuthors = Array.from(contacts).filter(pk => !seenAuthors.has(pk)) - if (finalAuthors.length > 0) { - DebugBus.info('debug', `Fetching highlights from ${finalAuthors.length} remaining friends`) - await fetchHighlightsFromAuthors(relayPool, finalAuthors, (h) => { - if (firstEventTime === null) { - firstEventTime = performance.now() - start - setTFirstHighlight(Math.round(firstEventTime)) - } - setHighlightEvents(prev => { - if (prev.some(x => x.id === h.id)) return prev - 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) - }) + setHighlightEvents(prev => { + if (prev.some(x => x.id === h.id)) return prev + 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) }) - } + }) } finally { setIsLoadingHighlights(false) const elapsed = Math.round(performance.now() - start) @@ -571,19 +554,20 @@ const Debug: React.FC = ({ DebugBus.warn('debug', 'Please log in to load friends list') return } - setFriendsLoading(true) - setFriendsPubkeys(new Set()) - DebugBus.info('debug', 'Loading friends list...') + DebugBus.info('debug', 'Loading friends list via controller...') + + // Subscribe to controller updates + const unsubscribe = contactsController.onContacts((contacts) => { + setFriendsPubkeys(new Set(contacts)) + }) + try { - const final = await fetchContacts( - relayPool, - activeAccount.pubkey, - (partial) => setFriendsPubkeys(new Set(partial)) - ) - setFriendsPubkeys(new Set(final)) - DebugBus.info('debug', `Loaded ${final.size} friends`) + // Force reload to see streaming behavior + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true }) + const final = contactsController.getContacts() + DebugBus.info('debug', `Loaded ${final.size} friends from controller`) } finally { - setFriendsLoading(false) + unsubscribe() } } diff --git a/src/services/contactsController.ts b/src/services/contactsController.ts new file mode 100644 index 00000000..b6f08b75 --- /dev/null +++ b/src/services/contactsController.ts @@ -0,0 +1,114 @@ +import { RelayPool } from 'applesauce-relay' +import { fetchContacts } from './contactService' + +type ContactsCallback = (contacts: Set) => void +type LoadingCallback = (loading: boolean) => void + +/** + * Shared contacts/friends controller + * Manages the user's follow list centrally, similar to bookmarkController + */ +class ContactsController { + private contactsListeners: ContactsCallback[] = [] + private loadingListeners: LoadingCallback[] = [] + + private currentContacts: Set = new Set() + private lastLoadedPubkey: string | null = null + + onContacts(cb: ContactsCallback): () => void { + this.contactsListeners.push(cb) + return () => { + this.contactsListeners = this.contactsListeners.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 emitContacts(contacts: Set): void { + this.contactsListeners.forEach(cb => cb(contacts)) + } + + /** + * Get current contacts without triggering a reload + */ + getContacts(): Set { + return new Set(this.currentContacts) + } + + /** + * Check if contacts are loaded for a specific pubkey + */ + isLoadedFor(pubkey: string): boolean { + return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0 + } + + /** + * Reset state (for logout or manual refresh) + */ + reset(): void { + this.currentContacts.clear() + this.lastLoadedPubkey = null + this.emitContacts(this.currentContacts) + } + + /** + * Load contacts for a user + * Streams partial results and caches the final list + */ + async start(options: { + relayPool: RelayPool + pubkey: string + force?: boolean + }): Promise { + const { relayPool, pubkey, force = false } = options + + // Skip if already loaded for this pubkey (unless forced) + if (!force && this.isLoadedFor(pubkey)) { + console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8)) + this.emitContacts(this.currentContacts) + return + } + + this.setLoading(true) + console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8)) + + try { + const contacts = await fetchContacts( + relayPool, + pubkey, + (partial) => { + // Stream partial updates + this.currentContacts = new Set(partial) + this.emitContacts(this.currentContacts) + console.log('[contacts] 📥 Partial contacts:', partial.size) + } + ) + + // Store final result + this.currentContacts = new Set(contacts) + this.lastLoadedPubkey = pubkey + this.emitContacts(this.currentContacts) + + console.log('[contacts] ✅ Loaded', contacts.size, 'contacts') + } catch (error) { + console.error('[contacts] ❌ Failed to load contacts:', error) + this.currentContacts.clear() + this.emitContacts(this.currentContacts) + } finally { + this.setLoading(false) + } + } +} + +// Singleton instance +export const contactsController = new ContactsController() +