feat: implement centralized highlights controller

- Create highlightsController with subscription API and event store integration
- Auto-load user highlights on app start (alongside bookmarks and contacts)
- Store highlight events in applesauce event store for offline support
- Update Me.tsx to use controller for own profile highlights
- Add optional eventStore parameter to all highlight fetch functions
- Pass eventStore through Debug component for persistent storage
- Implement incremental sync with localStorage-based lastSyncedAt tracking
- Add generation-based cancellation for in-flight requests
- Reset highlights on logout

Closes #highlights-controller
This commit is contained in:
Gigi
2025-10-18 21:19:57 +02:00
parent a275c0a8e3
commit 530cc20cba
8 changed files with 321 additions and 15 deletions

View File

@@ -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<Set<string>>(new Set())
const [contactsLoading, setContactsLoading] = useState(false)
// Centralized highlights state (fed by controller)
const [highlights, setHighlights] = useState<Highlight[]>([])
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={
<Debug
relayPool={relayPool}
eventStore={eventStore}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
@@ -675,7 +710,7 @@ function App() {
<AccountsProvider manager={accountManager}>
<BrowserRouter>
<div className="min-h-screen p-0 max-w-none m-0 relative">
<AppRoutes relayPool={relayPool} showToast={showToast} />
<AppRoutes relayPool={relayPool} eventStore={eventStore} showToast={showToast} />
<RouteDebug />
</div>
</BrowserRouter>

View File

@@ -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<void>
@@ -32,6 +32,7 @@ interface DebugProps {
const Debug: React.FC<DebugProps> = ({
relayPool,
eventStore,
bookmarks,
bookmarksLoading,
onRefreshBookmarks,
@@ -40,11 +41,10 @@ const Debug: React.FC<DebugProps> = ({
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<DebugProps> = ({
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<DebugProps> = ({
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)

View File

@@ -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<MeProps> = ({
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)

View File

@@ -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<Highlight[]> => {
// 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) {

View File

@@ -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<Highlight[]> => {
// 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) {

View File

@@ -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<Highlight[]> => {
// 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)

View File

@@ -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<Highlight[]> => {
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)

View File

@@ -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<void> {
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<string>()
const highlightsMap = new Map<string, Highlight>()
// 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()