mirror of
https://github.com/dergigi/boris.git
synced 2026-02-20 14:34:29 +01:00
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:
36
src/App.tsx
36
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<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')
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
114
src/services/contactsController.ts
Normal file
114
src/services/contactsController.ts
Normal 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()
|
||||
|
||||
Reference in New Issue
Block a user