feat: add centralized contacts controller

- Create contactsController similar to bookmarkController
- Manage friends/contacts list in one place across the app
- Auto-load contacts on login, cache results per pubkey
- Stream partial contacts as they arrive
- Update App.tsx to subscribe to contacts controller
- Update Debug.tsx to use centralized contacts instead of fetching directly
- Reset contacts on logout
- Contacts won't reload unnecessarily (cached by pubkey)
- Debug 'Load Friends' button forces reload to show streaming behavior
This commit is contained in:
Gigi
2025-10-18 20:51:03 +02:00
parent 8030e2fa00
commit d6a913f2a6
3 changed files with 192 additions and 58 deletions

View File

@@ -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<Bookmark[]>([])
const [bookmarksLoading, setBookmarksLoading] = useState(false)
// Centralized contacts state (fed by controller)
const [contacts, setContacts] = useState<Set<string>>(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')
}

View File

@@ -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<DebugProps> = ({
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<DebugProps> = ({
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<string>()
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<DebugProps> = ({
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()
}
}

View File

@@ -0,0 +1,114 @@
import { RelayPool } from 'applesauce-relay'
import { fetchContacts } from './contactService'
type ContactsCallback = (contacts: Set<string>) => 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<string> = 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<string>): void {
this.contactsListeners.forEach(cb => cb(contacts))
}
/**
* Get current contacts without triggering a reload
*/
getContacts(): Set<string> {
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<void> {
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()