diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d535f51..466a0993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,124 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.7.0] - 2025-10-18 + +### Added + +- Login with Bunker (NIP-46) authentication support + - Support for remote signing via Nostr Connect protocol + - Bunker URI input with validation and error handling + - Automatic reconnection on app restore with proper permissions + - Signer suggestions in error messages (Amber, nsec.app, Nostrum) +- Debug page (`/debug`) for diagnostics and testing + - Interactive NIP-04 and NIP-44 encryption/decryption testing + - Live performance timing with stopwatch display + - Bookmark loading and decryption diagnostics + - Real-time bunker logs with filtering and clearing + - Version and git commit footer +- Progressive bookmark loading with streaming updates + - Non-blocking, progressive bookmark updates via callback pattern + - Batched background hydration using EventLoader and AddressLoader + - Auto-decrypt bookmarks as they arrive from relays + - Individual decrypt buttons for encrypted bookmark events +- Bookmark grouping toggle (grouped by source vs flat chronological) + - Toggle between grouped view and flat chronological list + - Amethyst-style bookmark detection and grouping + - Display bookmarks even when they only have IDs (content loads in background) + +### Changed + +- Improved login UI with better copy and modern design + - Personable title and nostr-native language + - Highlighted 'your own highlights' in login copy + - Simplified button text to single words (Extension, Signer) + - Hide login button and user icon when logged out + - Hide Extension button when Bunker input is shown + - Auto-load bookmarks on login and page mount +- Enhanced bunker error messages + - Formatted error messages with signer suggestions + - Links to nos2x, Amber, nsec.app, and Nostrum signers + - Better error handling for missing signer extensions + - Centered and constrained bunker input field +- Centralized bookmark loading architecture + - Single shared bookmark controller for consistent loading + - Unified bookmark loading with streaming and auto-decrypt + - Consolidated bookmark loading into single centralized function + - Bookmarks passed as props throughout component tree +- Renamed UI elements for clarity + - "Bunker" button renamed to "Signer" + - Hide bookmark controls when logged out +- Settings version footer improvements + - Separate links for version (to GitHub release) and commit (to commit page) + - Proper spacing around middot separator + +### Fixed + +- NIP-46 bunker signing and decryption + - NostrConnectSigner properly reconnects with permissions on app restore + - Bunker relays added to relay pool for signing requests + - Proper setup of pool and relays before bunker reconnection + - Expose nip04/nip44 on NostrConnectAccount for bookmark decryption + - Cache wrapped nip04/nip44 objects instead of using getters + - Wait for bunker relay connections before marking signer ready + - Validate bunker URI (remote must differ from user pubkey) + - Accept remote===pubkey for Amber compatibility +- Bookmark loading and decryption + - Bookmarks load and complete properly with streaming + - Auto-decrypt private bookmarks with NIP-04 detection + - Include decrypted private bookmarks in sidebar + - Skip background event fetching when there are too many IDs + - Only build bookmarks from ready events (unencrypted or decrypted) + - Restore Debug page decrypt display via onDecryptComplete callback + - Make controller onEvent non-blocking for queryEvents completion + - Proper timeout handling for bookmark decryption (no hanging) + - Smart encryption detection with consistent padlock display + - Sequential decryption instead of concurrent to avoid queue issues + - Add extraRelays to EventLoader and AddressLoader +- PWA cache limit increased to 3 MiB for larger bundles +- Extension login error messages with nos2x link +- TypeScript and linting errors throughout + - Replace empty catch blocks with warnings + - Fix explicit any types + - Add missing useEffect dependencies + - Resolve all linting issues in App.tsx, Debug.tsx, and async utilities + +### Performance + +- Non-blocking NIP-46 operations + - Fire-and-forget NIP-46 publish for better UI responsiveness + - Non-blocking bookmark decryption with sequential processing + - Make controller onEvent non-blocking for queryEvents completion +- Optimized bookmark loading + - Batched background hydration using EventLoader and AddressLoader + - Progressive, non-blocking bookmark loading with streaming + - Shorter timeouts for debug page bookmark loading + - Remove artificial delays from bookmark decryption + +### Refactored + +- Centralized bookmark controller architecture + - Extract bookmark streaming helpers and centralize loading + - Consolidated bookmark loading into single function + - Remove deprecated bookmark service files + - Share bookmark controller between components +- Debug page organization + - Extract VersionFooter component to eliminate duplication + - Structured sections with proper layout and styling + - Apply settings page styling structure +- Simplified bunker implementation following applesauce patterns + - Clean up bunker implementation for better maintainability + - Import RELAYS from central config (DRY principle) + - Update RELAYS list with relay.nsec.app + +### Documentation + +- Comprehensive Amber.md documentation + - Amethyst-style bookmarks section + - Bunker decrypt investigation summary + - Critical queue disabling requirement + - NIP-46 setup and troubleshooting + ## [0.6.24] - 2025-01-16 ### Fixed @@ -1760,7 +1878,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Optimize relay usage following applesauce-relay best practices - Use applesauce-react event models for better profile handling -[Unreleased]: https://github.com/dergigi/boris/compare/v0.6.24...HEAD +[Unreleased]: https://github.com/dergigi/boris/compare/v0.7.0...HEAD +[0.7.0]: https://github.com/dergigi/boris/compare/v0.6.24...v0.7.0 [0.6.24]: https://github.com/dergigi/boris/compare/v0.6.23...v0.6.24 [0.6.23]: https://github.com/dergigi/boris/compare/v0.6.22...v0.6.23 [0.6.21]: https://github.com/dergigi/boris/compare/v0.6.20...v0.6.21 diff --git a/package.json b/package.json index 5f667987..ec78f599 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "boris", - "version": "0.6.24", + "version": "0.7.1", "description": "A minimal nostr client for bookmark management", "homepage": "https://read.withboris.com/", "type": "module", diff --git a/src/App.tsx b/src/App.tsx index dd1e2c6c..ee0f1e95 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,8 @@ 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' +import { highlightsController } from './services/highlightsController' const DEFAULT_ARTICLE = import.meta.env.VITE_DEFAULT_ARTICLE_NADDR || 'naddr1qvzqqqr4gupzqmjxss3dld622uu8q25gywum9qtg4w4cv4064jmg20xsac2aam5nqqxnzd3cxqmrzv3exgmr2wfesgsmew' @@ -28,9 +30,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() @@ -40,6 +44,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,13 +67,50 @@ function AppRoutes({ } }, []) - // Auto-load bookmarks when account is ready (on login or page mount) + // Subscribe to contacts controller useEffect(() => { - if (activeAccount && relayPool && bookmarks.length === 0 && !bookmarksLoading) { - console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login') - bookmarkController.start({ relayPool, activeAccount, accountManager }) + 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() } - }, [activeAccount, relayPool, bookmarks.length, bookmarksLoading, accountManager]) + }, []) + + + // 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 + + // Load bookmarks + if (bookmarks.length === 0 && !bookmarksLoading) { + console.log('[bookmark] 🚀 Auto-loading bookmarks on mount/login') + bookmarkController.start({ relayPool, activeAccount, accountManager }) + } + + // Load contacts + if (pubkey && contacts.size === 0 && !contactsLoading) { + console.log('[contacts] 🚀 Auto-loading contacts on mount/login') + contactsController.start({ relayPool, pubkey }) + } + + // Load highlights (controller manages its own state) + if (pubkey && eventStore && !highlightsController.isLoadedFor(pubkey)) { + console.log('[highlights] 🚀 Auto-loading highlights on mount/login') + highlightsController.start({ relayPool, eventStore, pubkey }) + } + } + }, [activeAccount, relayPool, eventStore, bookmarks.length, bookmarksLoading, contacts.size, contactsLoading, accountManager]) // Manual refresh (for sidebar button) const handleRefreshBookmarks = useCallback(async () => { @@ -81,6 +126,8 @@ function AppRoutes({ const handleLogout = () => { accountManager.clearActive() bookmarkController.reset() // Clear bookmarks via controller + contactsController.reset() // Clear contacts via controller + highlightsController.reset() // Clear highlights via controller showToast('Logged out successfully') } @@ -263,6 +310,7 @@ function AppRoutes({ element={
- +
diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx index f8617898..47c3f751 100644 --- a/src/components/Bookmarks.tsx +++ b/src/components/Bookmarks.tsx @@ -35,7 +35,7 @@ const Bookmarks: React.FC = ({ onLogout, bookmarks, bookmarksLoading, - onRefreshBookmarks + onRefreshBookmarks }) => { const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>() const location = useLocation() @@ -179,6 +179,7 @@ const Bookmarks: React.FC = ({ currentArticleCoordinate, currentArticleEventId, settings, + eventStore, onRefreshBookmarks }) @@ -242,6 +243,7 @@ const Bookmarks: React.FC = ({ useExternalUrlLoader({ url: externalUrl, relayPool, + eventStore, setSelectedUrl, setReaderContent, setReaderLoading, @@ -325,10 +327,10 @@ const Bookmarks: React.FC = ({ relayPool ? : null ) : undefined} me={showMe ? ( - relayPool ? : null + relayPool ? : null ) : undefined} profile={showProfile && profilePubkey ? ( - relayPool ? : null + relayPool ? : null ) : undefined} support={showSupport ? ( relayPool ? : null diff --git a/src/components/Debug.tsx b/src/components/Debug.tsx index e85bdbe3..cfde73b3 100644 --- a/src/components/Debug.tsx +++ b/src/components/Debug.tsx @@ -3,11 +3,11 @@ 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' import ThreePaneLayout from './ThreePaneLayout' @@ -16,11 +16,14 @@ import type { NostrEvent } from '../services/bookmarkHelpers' import { Bookmark } from '../types/bookmarks' import { useBookmarksUI } from '../hooks/useBookmarksUI' import { useSettings } from '../hooks/useSettings' +import { fetchHighlights, fetchHighlightsFromAuthors } from '../services/highlightService' +import { contactsController } from '../services/contactsController' 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 @@ -29,6 +32,7 @@ interface DebugProps { const Debug: React.FC = ({ relayPool, + eventStore, bookmarks, bookmarksLoading, onRefreshBookmarks, @@ -37,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 }) @@ -76,17 +79,33 @@ const Debug: React.FC = ({ const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null) const [tLoadBookmarks, setTLoadBookmarks] = useState(null) const [tDecryptBookmarks, setTDecryptBookmarks] = useState(null) + const [tFirstBookmark, setTFirstBookmark] = useState(null) // Individual event decryption results const [decryptedEvents, setDecryptedEvents] = useState>(new Map()) + // Highlight loading state + const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author') + const [highlightArticleCoord, setHighlightArticleCoord] = useState('') + const [highlightUrl, setHighlightUrl] = useState('') + const [highlightAuthor, setHighlightAuthor] = useState('') + const [isLoadingHighlights, setIsLoadingHighlights] = useState(false) + const [highlightEvents, setHighlightEvents] = useState([]) + const [tLoadHighlights, setTLoadHighlights] = useState(null) + const [tFirstHighlight, setTFirstHighlight] = useState(null) + // Live timing state const [liveTiming, setLiveTiming] = useState<{ nip44?: { type: 'encrypt' | 'decrypt'; startTime: number } nip04?: { type: 'encrypt' | 'decrypt'; startTime: number } loadBookmarks?: { startTime: number } decryptBookmarks?: { startTime: number } + loadHighlights?: { startTime: number } }>({}) + + // Web of Trust state + const [friendsPubkeys, setFriendsPubkeys] = useState>(new Set()) + const [friendsButtonLoading, setFriendsButtonLoading] = useState(false) useEffect(() => { return DebugBus.subscribe((e) => setLogs(prev => [...prev, e].slice(-300))) @@ -243,10 +262,12 @@ const Debug: React.FC = ({ setBookmarkStats(null) setBookmarkEvents([]) // Clear existing events setDecryptedEvents(new Map()) + setTFirstBookmark(null) DebugBus.info('debug', 'Loading bookmark events...') // Start timing const start = performance.now() + let firstEventTime: number | null = null setLiveTiming(prev => ({ ...prev, loadBookmarks: { startTime: start } })) // Import controller at runtime to avoid circular dependencies @@ -254,6 +275,12 @@ const Debug: React.FC = ({ // Subscribe to raw events for Debug UI display const unsubscribeRaw = bookmarkController.onRawEvent((evt) => { + // Track time to first event + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstBookmark(Math.round(firstEventTime)) + } + // Add event immediately with live deduplication setBookmarkEvents(prev => { const key = getEventKey(evt) @@ -311,10 +338,243 @@ const Debug: React.FC = ({ setBookmarkStats(null) setTLoadBookmarks(null) setTDecryptBookmarks(null) + setTFirstBookmark(null) setDecryptedEvents(new Map()) DebugBus.info('debug', 'Cleared bookmark data') } + const handleLoadHighlights = async () => { + if (!relayPool) { + DebugBus.warn('debug', 'Cannot load highlights: missing relayPool') + return + } + + // Default to logged-in user's highlights if no specific query provided + const getValue = () => { + if (highlightMode === 'article') return highlightArticleCoord.trim() + if (highlightMode === 'url') return highlightUrl.trim() + const authorValue = highlightAuthor.trim() + return authorValue || pubkey || '' + } + + const value = getValue() + if (!value) { + DebugBus.warn('debug', 'Please provide a value to query or log in') + return + } + + try { + setIsLoadingHighlights(true) + setHighlightEvents([]) + setTFirstHighlight(null) + DebugBus.info('debug', `Loading highlights (${highlightMode}: ${value})...`) + + const start = performance.now() + setLiveTiming(prev => ({ ...prev, loadHighlights: { startTime: start } })) + + let firstEventTime: number | null = null + const seenIds = new Set() + + // Import highlight services + const { queryEvents } = await import('../services/dataFetch') + const { KINDS } = await import('../config/kinds') + + // Build filter based on mode + let filter: { kinds: number[]; '#a'?: string[]; '#r'?: string[]; authors?: string[] } + if (highlightMode === 'article') { + filter = { kinds: [KINDS.Highlights], '#a': [value] } + } else if (highlightMode === 'url') { + filter = { kinds: [KINDS.Highlights], '#r': [value] } + } else { + filter = { kinds: [KINDS.Highlights], authors: [value] } + } + + const events = await queryEvents(relayPool, filter, { + onEvent: (evt) => { + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + + setHighlightEvents(prev => [...prev, evt]) + } + }) + + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + setLiveTiming(prev => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, no-unused-vars + const { loadHighlights, ...rest } = prev + return rest + }) + + DebugBus.info('debug', `Loaded ${events.length} highlight events in ${elapsed}ms`) + } catch (err) { + console.error('Failed to load highlights:', err) + DebugBus.error('debug', `Failed to load highlights: ${err instanceof Error ? err.message : String(err)}`) + } finally { + setIsLoadingHighlights(false) + } + } + + const handleClearHighlights = () => { + setHighlightEvents([]) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Cleared highlight data') + } + + const handleLoadMyHighlights = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load your highlights') + return + } + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Loading my highlights...') + try { + let firstEventTime: number | null = null + await fetchHighlights(relayPool, activeAccount.pubkey, (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) + }) + }, settings, false, eventStore || undefined) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded my highlights in ${elapsed}ms`) + } + } + + const handleLoadFriendsHighlights = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load friends highlights') + return + } + + // Get contacts from centralized controller (should already be loaded by App.tsx) + const contacts = contactsController.getContacts() + if (contacts.size === 0) { + DebugBus.warn('debug', 'No friends found. Make sure you have contacts loaded.') + return + } + + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', `Loading highlights from ${contacts.size} friends (using cached contacts)...`) + + let firstEventTime: number | null = null + + try { + await fetchHighlightsFromAuthors(relayPool, Array.from(contacts), (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) + }) + }, eventStore || undefined) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded friends highlights in ${elapsed}ms`) + } + } + + const handleLoadNostrverseHighlights = async () => { + if (!relayPool) { + DebugBus.warn('debug', 'Relay pool not available') + return + } + const start = performance.now() + setHighlightEvents([]) + setIsLoadingHighlights(true) + setTLoadHighlights(null) + setTFirstHighlight(null) + DebugBus.info('debug', 'Loading nostrverse highlights (kind:9802)...') + try { + let firstEventTime: number | null = null + const seenIds = new Set() + const { queryEvents } = await import('../services/dataFetch') + + const events = await queryEvents(relayPool, { kinds: [9802], limit: 500 }, { + onEvent: (evt) => { + if (seenIds.has(evt.id)) return + seenIds.add(evt.id) + if (firstEventTime === null) { + firstEventTime = performance.now() - start + setTFirstHighlight(Math.round(firstEventTime)) + } + setHighlightEvents(prev => [...prev, evt]) + } + }) + + DebugBus.info('debug', `Loaded ${events.length} nostrverse highlights`) + } finally { + setIsLoadingHighlights(false) + const elapsed = Math.round(performance.now() - start) + setTLoadHighlights(elapsed) + DebugBus.info('debug', `Loaded nostrverse highlights in ${elapsed}ms`) + } + } + + const handleLoadFriendsList = async () => { + if (!relayPool || !activeAccount?.pubkey) { + DebugBus.warn('debug', 'Please log in to load friends list') + return + } + + setFriendsButtonLoading(true) + DebugBus.info('debug', 'Loading friends list via controller...') + + // Clear current list + setFriendsPubkeys(new Set()) + + // Subscribe to controller updates to see streaming + const unsubscribe = contactsController.onContacts((contacts) => { + console.log('[debug] Received contacts update:', contacts.size) + setFriendsPubkeys(new Set(contacts)) + }) + + try { + // Force reload to see streaming behavior + await contactsController.start({ relayPool, pubkey: activeAccount.pubkey, force: true }) + const final = contactsController.getContacts() + setFriendsPubkeys(new Set(final)) + DebugBus.info('debug', `Loaded ${final.size} friends from controller`) + } catch (err) { + console.error('[debug] Failed to load friends:', err) + DebugBus.error('debug', `Failed to load friends: ${err instanceof Error ? err.message : String(err)}`) + } finally { + unsubscribe() + setFriendsButtonLoading(false) + } + } + + const friendsNpubs = useMemo(() => { + return Array.from(friendsPubkeys).map(pk => nip19.npubEncode(pk)) + }, [friendsPubkeys]) + const handleBunkerLogin = async () => { if (!bunkerUri.trim()) { setBunkerError('Please enter a bunker URI') @@ -376,7 +636,7 @@ const Debug: React.FC = ({ return null } - const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks') => { + const getBookmarkLiveTiming = (operation: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights') => { const timing = liveTiming[operation] if (timing) { const elapsed = Math.round(performance.now() - timing.startTime) @@ -390,7 +650,7 @@ const Debug: React.FC = ({ value?: string | number | null; mode?: 'nip44' | 'nip04'; type?: 'encrypt' | 'decrypt'; - bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks'; + bookmarkOp?: 'loadBookmarks' | 'decryptBookmarks' | 'loadHighlights'; }) => { const liveValue = bookmarkOp ? getBookmarkLiveTiming(bookmarkOp) : (mode && type ? getLiveTiming(mode, type) : null) const isLive = !!liveValue @@ -596,7 +856,8 @@ const Debug: React.FC = ({
- + +
@@ -647,6 +908,204 @@ const Debug: React.FC = ({ )} + {/* Highlight Loading Section */} +
+

Highlight Loading

+
Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.
+ +
+
Query Mode:
+
+ + + +
+
+ +
+ {highlightMode === 'article' && ( + setHighlightArticleCoord(e.target.value)} + disabled={isLoadingHighlights} + /> + )} + {highlightMode === 'url' && ( + setHighlightUrl(e.target.value)} + disabled={isLoadingHighlights} + /> + )} + {highlightMode === 'author' && ( + setHighlightAuthor(e.target.value)} + disabled={isLoadingHighlights} + /> + )} +
+ +
+ + +
+ +
Quick load options:
+
+ + + +
+ +
+ + +
+ + {highlightEvents.length > 0 && ( +
+
Loaded Highlights ({highlightEvents.length}):
+
+ {highlightEvents.map((evt, idx) => { + const content = evt.content || '' + const shortContent = content.length > 100 ? content.substring(0, 100) + '...' : content + const aTag = evt.tags?.find((t: string[]) => t[0] === 'a')?.[1] + const rTag = evt.tags?.find((t: string[]) => t[0] === 'r')?.[1] + const eTag = evt.tags?.find((t: string[]) => t[0] === 'e')?.[1] + const contextTag = evt.tags?.find((t: string[]) => t[0] === 'context')?.[1] + + return ( +
+
Highlight #{idx + 1}
+
+
Author: {evt.pubkey.slice(0, 16)}...
+
Created: {new Date(evt.created_at * 1000).toLocaleString()}
+
+
+
Content:
+
"{shortContent}"
+
+ {contextTag && ( +
+
Context: {contextTag.substring(0, 60)}...
+
+ )} + {aTag &&
#a: {aTag}
} + {rTag &&
#r: {rTag}
} + {eTag &&
#e: {eTag.slice(0, 16)}...
} +
ID: {evt.id}
+
+ ) + })} +
+
+ )} +
+ + {/* Web of Trust Section */} +
+

Web of Trust

+
Load your followed contacts (friends) for highlight fetching:
+ +
+ +
+ + {friendsPubkeys.size > 0 && ( +
+
Friends Count: {friendsNpubs.length}
+
+ {friendsNpubs.map(npub => ( +
+ {npub} +
+ ))} +
+
+ )} +
+ {/* Debug Logs Section */}

Debug Logs

diff --git a/src/components/Explore.tsx b/src/components/Explore.tsx index fae4c2ad..1b6069b6 100644 --- a/src/components/Explore.tsx +++ b/src/components/Explore.tsx @@ -1,18 +1,19 @@ -import React, { useState, useEffect, useMemo } from 'react' +import React, { useState, useEffect, useMemo, useCallback } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faNewspaper, faHighlighter, faUser, faUserGroup, faNetworkWired, faArrowsRotate, faSpinner } from '@fortawesome/free-solid-svg-icons' import IconButton from './IconButton' import { BlogPostSkeleton, HighlightSkeleton } from './Skeletons' import { Hooks } from 'applesauce-react' import { RelayPool } from 'applesauce-relay' -import { IEventStore } from 'applesauce-core' -import { nip19 } from 'nostr-tools' +import { IEventStore, Helpers } from 'applesauce-core' +import { nip19, NostrEvent } from 'nostr-tools' import { useNavigate } from 'react-router-dom' import { fetchContacts } from '../services/contactService' import { fetchBlogPostsFromAuthors, BlogPostPreview } from '../services/exploreService' import { fetchHighlightsFromAuthors } from '../services/highlightService' import { fetchProfiles } from '../services/profileService' import { fetchNostrverseBlogPosts, fetchNostrverseHighlights } from '../services/nostrverseService' +import { highlightsController } from '../services/highlightsController' import { Highlight } from '../types/highlights' import { UserSettings } from '../services/settingsService' import BlogPostCard from './BlogPostCard' @@ -22,6 +23,12 @@ import { usePullToRefresh } from 'use-pull-to-refresh' import RefreshIndicator from './RefreshIndicator' import { classifyHighlights } from '../utils/highlightClassification' import { HighlightVisibility } from './HighlightsPanel' +import { KINDS } from '../config/kinds' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { useStoreTimeline } from '../hooks/useStoreTimeline' +import { dedupeHighlightsById, dedupeWritingsByReplaceable } from '../utils/dedupe' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers interface ExploreProps { relayPool: RelayPool @@ -42,13 +49,41 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [loading, setLoading] = useState(true) const [refreshTrigger, setRefreshTrigger] = useState(0) - // Visibility filters (defaults from settings, or friends only) + // Get myHighlights directly from controller + const [myHighlights, setMyHighlights] = useState([]) + const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + + // Load cached content from event store (instant display) + const cachedHighlights = useStoreTimeline(eventStore, { kinds: [KINDS.Highlights] }, eventToHighlight, []) + + const toBlogPostPreview = useCallback((event: NostrEvent): BlogPostPreview => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + }), []) + + const cachedWritings = useStoreTimeline(eventStore, { kinds: [30023] }, toBlogPostPreview, []) + + // Visibility filters (defaults from settings) const [visibility, setVisibility] = useState({ - nostrverse: settings?.defaultHighlightVisibilityNostrverse ?? false, - friends: settings?.defaultHighlightVisibilityFriends ?? true, - mine: settings?.defaultHighlightVisibilityMine ?? false + nostrverse: settings?.defaultExploreScopeNostrverse ?? false, + friends: settings?.defaultExploreScopeFriends ?? true, + mine: settings?.defaultExploreScopeMine ?? false }) + // Subscribe to highlights controller + useEffect(() => { + const unsubHighlights = highlightsController.onHighlights(setMyHighlights) + const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) + return () => { + unsubHighlights() + unsubLoading() + } + }, []) + // Update local state when prop changes useEffect(() => { if (propActiveTab) { @@ -68,14 +103,34 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti setLoading(true) // Seed from in-memory cache if available to avoid empty flash - // Use functional update to check current state without creating dependency - const cachedPosts = getCachedPosts(activeAccount.pubkey) - if (cachedPosts && cachedPosts.length > 0) { - setBlogPosts(prev => prev.length === 0 ? cachedPosts : prev) + const memoryCachedPosts = getCachedPosts(activeAccount.pubkey) + if (memoryCachedPosts && memoryCachedPosts.length > 0) { + setBlogPosts(prev => prev.length === 0 ? memoryCachedPosts : prev) } - const cachedHighlights = getCachedHighlights(activeAccount.pubkey) - if (cachedHighlights && cachedHighlights.length > 0) { - setHighlights(prev => prev.length === 0 ? cachedHighlights : prev) + const memoryCachedHighlights = getCachedHighlights(activeAccount.pubkey) + if (memoryCachedHighlights && memoryCachedHighlights.length > 0) { + setHighlights(prev => prev.length === 0 ? memoryCachedHighlights : prev) + } + + // Seed with cached content from event store (instant display) + if (cachedHighlights.length > 0 || myHighlights.length > 0) { + const merged = dedupeHighlightsById([...cachedHighlights, ...myHighlights]) + setHighlights(prev => { + const all = dedupeHighlightsById([...prev, ...merged]) + return all.sort((a, b) => b.created_at - a.created_at) + }) + } + + // Seed with cached writings from event store + if (cachedWritings.length > 0) { + setBlogPosts(prev => { + const all = dedupeWritingsByReplaceable([...prev, ...cachedWritings]) + return all.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + }) } // Fetch the user's contacts (friends) @@ -97,8 +152,31 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti relayUrls, (post) => { setBlogPosts((prev) => { - const exists = prev.some(p => p.event.id === post.event.id) - if (exists) return prev + // Deduplicate by author:d-tag (replaceable event key) + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existingIndex = prev.findIndex(p => { + const pDTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + return `${p.author}:${pDTag}` === key + }) + + // If exists, only replace if this one is newer + if (existingIndex >= 0) { + const existing = prev[existingIndex] + if (post.event.created_at <= existing.event.created_at) { + return prev // Keep existing (newer or same) + } + // Replace with newer version + const next = [...prev] + next[existingIndex] = post + return next.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + }) + } + + // New post, add it const next = [...prev, post] return next.sort((a, b) => { const timeA = a.published || a.event.created_at @@ -110,9 +188,27 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } ).then((all) => { setBlogPosts((prev) => { - const byId = new Map(prev.map(p => [p.event.id, p])) - for (const post of all) byId.set(post.event.id, post) - const merged = Array.from(byId.values()).sort((a, b) => { + // Deduplicate by author:d-tag (replaceable event key) + const byKey = new Map() + + // Add existing posts + for (const p of prev) { + const dTag = p.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${p.author}:${dTag}` + byKey.set(key, p) + } + + // Merge in new posts (keeping newer versions) + for (const post of all) { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existing = byKey.get(key) + if (!existing || post.event.created_at > existing.event.created_at) { + byKey.set(key, post) + } + } + + const merged = Array.from(byKey.values()).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA @@ -160,33 +256,21 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti const [friendsPosts, friendsHighlights, nostrversePosts, nostriverseHighlights] = await Promise.all([ fetchBlogPostsFromAuthors(relayPool, contactsArray, relayUrls), fetchHighlightsFromAuthors(relayPool, contactsArray), - fetchNostrverseBlogPosts(relayPool, relayUrls, 50), - fetchNostrverseHighlights(relayPool, 100) + fetchNostrverseBlogPosts(relayPool, relayUrls, 50, eventStore || undefined), + fetchNostrverseHighlights(relayPool, 100, eventStore || undefined) ]) // Merge and deduplicate all posts const allPosts = [...friendsPosts, ...nostrversePosts] - const postsByKey = new Map() - for (const post of allPosts) { - const key = `${post.author}:${post.event.tags.find(t => t[0] === 'd')?.[1] || ''}` - const existing = postsByKey.get(key) - if (!existing || post.event.created_at > existing.event.created_at) { - postsByKey.set(key, post) - } - } - const uniquePosts = Array.from(postsByKey.values()).sort((a, b) => { + const uniquePosts = dedupeWritingsByReplaceable(allPosts).sort((a, b) => { const timeA = a.published || a.event.created_at const timeB = b.published || b.event.created_at return timeB - timeA }) - // Merge and deduplicate all highlights - const allHighlights = [...friendsHighlights, ...nostriverseHighlights] - const highlightsByKey = new Map() - for (const highlight of allHighlights) { - highlightsByKey.set(highlight.id, highlight) - } - const uniqueHighlights = Array.from(highlightsByKey.values()).sort((a, b) => b.created_at - a.created_at) + // Merge and deduplicate all highlights (mine from controller + friends + nostrverse) + const allHighlights = [...myHighlights, ...friendsHighlights, ...nostriverseHighlights] + const uniqueHighlights = dedupeHighlightsById(allHighlights).sort((a, b) => b.created_at - a.created_at) // Fetch profiles for all blog post authors to cache them if (uniquePosts.length > 0) { @@ -211,7 +295,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti } loadData() - }, [relayPool, activeAccount, refreshTrigger, eventStore, settings]) + }, [relayPool, activeAccount, refreshTrigger, eventStore, settings, myHighlights, cachedHighlights, cachedWritings]) // Pull-to-refresh const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -340,7 +424,7 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || blogPosts.length > 0 - const showSkeletons = loading && !hasData + const showSkeletons = (loading || myHighlightsLoading) && !hasData return (
@@ -422,7 +506,9 @@ const Explore: React.FC = ({ relayPool, eventStore, settings, acti
- {renderTabContent()} +
+ {renderTabContent()} +
) } diff --git a/src/components/Me.tsx b/src/components/Me.tsx index 0e74dd74..70ff766b 100644 --- a/src/components/Me.tsx +++ b/src/components/Me.tsx @@ -1,14 +1,16 @@ -import React, { useState, useEffect } from 'react' +import React, { useState, useEffect, useMemo } from 'react' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faHighlighter, faBookmark, faList, faThLarge, faImage, faPenToSquare, faLink, faLayerGroup, faBars } from '@fortawesome/free-solid-svg-icons' import { Hooks } from 'applesauce-react' +import { IEventStore, Helpers } from 'applesauce-core' import { BlogPostSkeleton, HighlightSkeleton, BookmarkSkeleton } from './Skeletons' import { RelayPool } from 'applesauce-relay' -import { nip19 } from 'nostr-tools' +import { nip19, NostrEvent } from 'nostr-tools' 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' @@ -31,9 +33,15 @@ import { filterByReadingProgress } from '../utils/readingProgressUtils' import { deriveReadsFromBookmarks } from '../utils/readsFromBookmarks' import { deriveLinksFromBookmarks } from '../utils/linksFromBookmarks' import { mergeReadItem } from '../utils/readItemMerge' +import { useStoreTimeline } from '../hooks/useStoreTimeline' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { KINDS } from '../config/kinds' + +const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary } = Helpers interface MeProps { relayPool: RelayPool + eventStore: IEventStore activeTab?: TabType pubkey?: string // Optional pubkey for viewing other users' profiles bookmarks: Bookmark[] // From centralized App.tsx state @@ -47,6 +55,7 @@ const VALID_FILTERS: ReadingProgressFilterType[] = ['all', 'unopened', 'started' const Me: React.FC = ({ relayPool, + eventStore, activeTab: propActiveTab, pubkey: propPubkey, bookmarks @@ -67,6 +76,34 @@ const Me: React.FC = ({ const [writings, setWritings] = useState([]) const [loading, setLoading] = useState(true) const [loadedTabs, setLoadedTabs] = useState>(new Set()) + + // Get myHighlights directly from controller + const [myHighlights, setMyHighlights] = useState([]) + const [myHighlightsLoading, setMyHighlightsLoading] = useState(false) + + // Load cached data from event store for OTHER profiles (not own) + const cachedHighlights = useStoreTimeline( + eventStore, + !isOwnProfile && viewingPubkey ? { kinds: [KINDS.Highlights], authors: [viewingPubkey] } : { kinds: [KINDS.Highlights], limit: 0 }, + eventToHighlight, + [viewingPubkey, isOwnProfile] + ) + + const toBlogPostPreview = useMemo(() => (event: NostrEvent): BlogPostPreview => ({ + event, + title: getArticleTitle(event) || 'Untitled', + summary: getArticleSummary(event), + image: getArticleImage(event), + published: getArticlePublished(event), + author: event.pubkey + }), []) + + const cachedWritings = useStoreTimeline( + eventStore, + !isOwnProfile && viewingPubkey ? { kinds: [30023], authors: [viewingPubkey] } : { kinds: [30023], limit: 0 }, + toBlogPostPreview, + [viewingPubkey, isOwnProfile] + ) const [viewMode, setViewMode] = useState('cards') const [refreshTrigger, setRefreshTrigger] = useState(0) const [bookmarkFilter, setBookmarkFilter] = useState('all') @@ -87,6 +124,20 @@ const Me: React.FC = ({ : 'all' const [readingProgressFilter, setReadingProgressFilter] = useState(initialFilter) + // Subscribe to highlights controller + useEffect(() => { + // Get initial state immediately + setMyHighlights(highlightsController.getHighlights()) + + // Subscribe to updates + const unsubHighlights = highlightsController.onHighlights(setMyHighlights) + const unsubLoading = highlightsController.onLoading(setMyHighlightsLoading) + return () => { + unsubHighlights() + unsubLoading() + } + }, []) + // Update local state when prop changes useEffect(() => { if (propActiveTab) { @@ -123,8 +174,20 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) - const userHighlights = await fetchHighlights(relayPool, viewingPubkey) - setHighlights(userHighlights) + + // For own profile, highlights come from controller subscription (sync effect handles it) + // For viewing other users, seed with cached data then fetch fresh + if (!isOwnProfile) { + // Seed with cached highlights first + if (cachedHighlights.length > 0) { + setHighlights(cachedHighlights.sort((a, b) => b.created_at - a.created_at)) + } + + // Fetch fresh highlights + const userHighlights = await fetchHighlights(relayPool, viewingPubkey) + setHighlights(userHighlights) + } + setLoadedTabs(prev => new Set(prev).add('highlights')) } catch (err) { console.error('Failed to load highlights:', err) @@ -140,6 +203,17 @@ const Me: React.FC = ({ try { if (!hasBeenLoaded) setLoading(true) + + // Seed with cached writings first + if (!isOwnProfile && cachedWritings.length > 0) { + setWritings(cachedWritings.sort((a, b) => { + const timeA = a.published || a.event.created_at + const timeB = b.published || b.event.created_at + return timeB - timeA + })) + } + + // Fetch fresh writings const userWritings = await fetchBlogPostsFromAuthors(relayPool, [viewingPubkey], RELAYS) setWritings(userWritings) setLoadedTabs(prev => new Set(prev).add('writings')) @@ -294,6 +368,12 @@ const Me: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [activeTab, viewingPubkey, refreshTrigger]) + // Sync myHighlights from controller when viewing own profile + useEffect(() => { + if (isOwnProfile) { + setHighlights(myHighlights) + } + }, [isOwnProfile, myHighlights]) // Pull-to-refresh - reload active tab without clearing state const { isRefreshing, pullPosition } = usePullToRefresh({ @@ -414,7 +494,7 @@ const Me: React.FC = ({ // Show content progressively - no blocking error screens const hasData = highlights.length > 0 || bookmarks.length > 0 || reads.length > 0 || links.length > 0 || writings.length > 0 - const showSkeletons = loading && !hasData + const showSkeletons = (loading || (isOwnProfile && myHighlightsLoading)) && !hasData const renderTabContent = () => { switch (activeTab) { @@ -428,7 +508,7 @@ const Me: React.FC = ({ ) } - return highlights.length === 0 && !loading ? ( + return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
No highlights yet.
diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 653697a5..f3001a06 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -6,6 +6,7 @@ import IconButton from './IconButton' import { loadFont } from '../utils/fontLoader' import ThemeSettings from './Settings/ThemeSettings' import ReadingDisplaySettings from './Settings/ReadingDisplaySettings' +import ExploreSettings from './Settings/ExploreSettings' import LayoutBehaviorSettings from './Settings/LayoutBehaviorSettings' import ZapSettings from './Settings/ZapSettings' import RelaySettings from './Settings/RelaySettings' @@ -29,6 +30,9 @@ const DEFAULT_SETTINGS: UserSettings = { defaultHighlightVisibilityNostrverse: true, defaultHighlightVisibilityFriends: true, defaultHighlightVisibilityMine: true, + defaultExploreScopeNostrverse: false, + defaultExploreScopeFriends: true, + defaultExploreScopeMine: false, zapSplitHighlighterWeight: 50, zapSplitBorisWeight: 2.1, zapSplitAuthorWeight: 50, @@ -163,6 +167,7 @@ const Settings: React.FC = ({ settings, onSave, onClose, relayPoo
+ diff --git a/src/components/Settings/ExploreSettings.tsx b/src/components/Settings/ExploreSettings.tsx new file mode 100644 index 00000000..84a96828 --- /dev/null +++ b/src/components/Settings/ExploreSettings.tsx @@ -0,0 +1,59 @@ +import React from 'react' +import { faNetworkWired, faUserGroup, faUser } from '@fortawesome/free-solid-svg-icons' +import { UserSettings } from '../../services/settingsService' +import IconButton from '../IconButton' + +interface ExploreSettingsProps { + settings: UserSettings + onUpdate: (updates: Partial) => void +} + +const ExploreSettings: React.FC = ({ settings, onUpdate }) => { + return ( +
+

Explore

+ +
+ +
+ onUpdate({ defaultExploreScopeNostrverse: !(settings.defaultExploreScopeNostrverse !== false) })} + title="Nostrverse content" + ariaLabel="Toggle nostrverse content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeNostrverse !== false) ? 'var(--highlight-color-nostrverse, #9333ea)' : undefined, + opacity: (settings.defaultExploreScopeNostrverse !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeFriends: !(settings.defaultExploreScopeFriends !== false) })} + title="Friends content" + ariaLabel="Toggle friends content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeFriends !== false) ? 'var(--highlight-color-friends, #f97316)' : undefined, + opacity: (settings.defaultExploreScopeFriends !== false) ? 1 : 0.4 + }} + /> + onUpdate({ defaultExploreScopeMine: !(settings.defaultExploreScopeMine !== false) })} + title="My content" + ariaLabel="Toggle my content by default in explore" + variant="ghost" + style={{ + color: (settings.defaultExploreScopeMine !== false) ? 'var(--highlight-color-mine, #eab308)' : undefined, + opacity: (settings.defaultExploreScopeMine !== false) ? 1 : 0.4 + }} + /> +
+
+
+ ) +} + +export default ExploreSettings + diff --git a/src/components/ThreePaneLayout.tsx b/src/components/ThreePaneLayout.tsx index b3912f90..2ebc0025 100644 --- a/src/components/ThreePaneLayout.tsx +++ b/src/components/ThreePaneLayout.tsx @@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC = (props) => { />
- {props.hasActiveAccount && ( + {props.hasActiveAccount && props.readerContent && ( Promise @@ -28,19 +34,50 @@ export const useBookmarksData = ({ currentArticleCoordinate, currentArticleEventId, settings, + eventStore, onRefreshBookmarks }: Omit) => { - const [highlights, setHighlights] = useState([]) + const [myHighlights, setMyHighlights] = useState([]) + const [articleHighlights, setArticleHighlights] = useState([]) const [highlightsLoading, setHighlightsLoading] = useState(true) const [followedPubkeys, setFollowedPubkeys] = useState>(new Set()) const [isRefreshing, setIsRefreshing] = useState(false) const [lastFetchTime, setLastFetchTime] = useState(null) - const handleFetchContacts = useCallback(async () => { - if (!relayPool || !activeAccount) return - const contacts = await fetchContacts(relayPool, activeAccount.pubkey) - setFollowedPubkeys(contacts) - }, [relayPool, activeAccount]) + // Load cached article-specific highlights from event store + const articleFilter = useMemo(() => { + if (!currentArticleCoordinate) return null + return { + kinds: [KINDS.Highlights], + '#a': [currentArticleCoordinate], + ...(currentArticleEventId ? { '#e': [currentArticleEventId] } : {}) + } + }, [currentArticleCoordinate, currentArticleEventId]) + + const cachedArticleHighlights = useStoreTimeline( + eventStore || null, + articleFilter || { kinds: [KINDS.Highlights], limit: 0 }, // empty filter if no article + eventToHighlight, + [currentArticleCoordinate, currentArticleEventId] + ) + + // Subscribe to centralized controllers + useEffect(() => { + // Get initial state immediately + setMyHighlights(highlightsController.getHighlights()) + setFollowedPubkeys(new Set(contactsController.getContacts())) + + // Subscribe to updates + const unsubHighlights = highlightsController.onHighlights(setMyHighlights) + const unsubContacts = contactsController.onContacts((contacts) => { + setFollowedPubkeys(new Set(contacts)) + }) + + return () => { + unsubHighlights() + unsubContacts() + } + }, []) const handleFetchHighlights = useCallback(async () => { if (!relayPool) return @@ -48,7 +85,16 @@ export const useBookmarksData = ({ setHighlightsLoading(true) try { if (currentArticleCoordinate) { + // Seed with cached highlights first + if (cachedArticleHighlights.length > 0) { + setArticleHighlights(cachedArticleHighlights.sort((a, b) => b.created_at - a.created_at)) + } + + // Fetch fresh article-specific highlights (from all users) const highlightsMap = new Map() + // Seed map with cached highlights + cachedArticleHighlights.forEach(h => highlightsMap.set(h.id, h)) + await fetchHighlightsForArticle( relayPool, currentArticleCoordinate, @@ -58,22 +104,23 @@ export const useBookmarksData = ({ if (!highlightsMap.has(highlight.id)) { highlightsMap.set(highlight.id, highlight) const highlightsList = Array.from(highlightsMap.values()) - setHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) + setArticleHighlights(highlightsList.sort((a, b) => b.created_at - a.created_at)) } }, - settings + settings, + false, // force + eventStore || undefined ) - console.log(`🔄 Refreshed ${highlightsMap.size} highlights for article`) - } else if (activeAccount) { - const fetchedHighlights = await fetchHighlights(relayPool, activeAccount.pubkey, undefined, settings) - setHighlights(fetchedHighlights) + } else { + // No article selected - clear article highlights + setArticleHighlights([]) } } catch (err) { console.error('Failed to fetch highlights:', err) } finally { setHighlightsLoading(false) } - }, [relayPool, activeAccount, currentArticleCoordinate, currentArticleEventId, settings]) + }, [relayPool, currentArticleCoordinate, currentArticleEventId, settings, eventStore, cachedArticleHighlights]) const handleRefreshAll = useCallback(async () => { if (!relayPool || !activeAccount || isRefreshing) return @@ -82,29 +129,37 @@ export const useBookmarksData = ({ try { await onRefreshBookmarks() await handleFetchHighlights() - await handleFetchContacts() + // Contacts and own highlights are managed by controllers setLastFetchTime(Date.now()) } catch (err) { console.error('Failed to refresh data:', err) } finally { setIsRefreshing(false) } - }, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, isRefreshing, onRefreshBookmarks, handleFetchHighlights]) - // Fetch highlights/contacts independently + // Fetch article-specific highlights when viewing an article useEffect(() => { if (!relayPool || !activeAccount) return - // Only fetch general highlights when not viewing an article (naddr) or external URL + // Fetch article-specific highlights when viewing an article // External URLs have their highlights fetched by useExternalUrlLoader - if (!naddr && !externalUrl) { + if (currentArticleCoordinate && !externalUrl) { handleFetchHighlights() + } else if (!naddr && !externalUrl) { + // Clear article highlights when not viewing an article + setArticleHighlights([]) + setHighlightsLoading(false) } - handleFetchContacts() - }, [relayPool, activeAccount, naddr, externalUrl, handleFetchHighlights, handleFetchContacts]) + }, [relayPool, activeAccount, currentArticleCoordinate, naddr, externalUrl, handleFetchHighlights]) + + // Merge highlights from controller with article-specific highlights + const highlights = [...myHighlights, ...articleHighlights] + .filter((h, i, arr) => arr.findIndex(x => x.id === h.id) === i) // Deduplicate + .sort((a, b) => b.created_at - a.created_at) return { highlights, - setHighlights, + setHighlights: setArticleHighlights, // For external updates (like from useExternalUrlLoader) highlightsLoading, setHighlightsLoading, followedPubkeys, diff --git a/src/hooks/useExternalUrlLoader.ts b/src/hooks/useExternalUrlLoader.ts index 598f1220..06aadda5 100644 --- a/src/hooks/useExternalUrlLoader.ts +++ b/src/hooks/useExternalUrlLoader.ts @@ -1,8 +1,12 @@ -import { useEffect } from 'react' +import { useEffect, useMemo } from 'react' import { RelayPool } from 'applesauce-relay' +import { IEventStore } from 'applesauce-core' import { fetchReadableContent, ReadableContent } from '../services/readerService' import { fetchHighlightsForUrl } from '../services/highlightService' import { Highlight } from '../types/highlights' +import { useStoreTimeline } from './useStoreTimeline' +import { eventToHighlight } from '../services/highlightEventProcessor' +import { KINDS } from '../config/kinds' // Helper to extract filename from URL function getFilenameFromUrl(url: string): string { @@ -20,6 +24,7 @@ function getFilenameFromUrl(url: string): string { interface UseExternalUrlLoaderProps { url: string | undefined relayPool: RelayPool | null + eventStore?: IEventStore | null setSelectedUrl: (url: string) => void setReaderContent: (content: ReadableContent | undefined) => void setReaderLoading: (loading: boolean) => void @@ -33,6 +38,7 @@ interface UseExternalUrlLoaderProps { export function useExternalUrlLoader({ url, relayPool, + eventStore, setSelectedUrl, setReaderContent, setReaderLoading, @@ -42,6 +48,19 @@ export function useExternalUrlLoader({ setCurrentArticleCoordinate, setCurrentArticleEventId }: UseExternalUrlLoaderProps) { + // Load cached URL-specific highlights from event store + const urlFilter = useMemo(() => { + if (!url) return null + return { kinds: [KINDS.Highlights], '#r': [url] } + }, [url]) + + const cachedUrlHighlights = useStoreTimeline( + eventStore || null, + urlFilter || { kinds: [KINDS.Highlights], limit: 0 }, + eventToHighlight, + [url] + ) + useEffect(() => { if (!relayPool || !url) return @@ -66,11 +85,20 @@ export function useExternalUrlLoader({ // Fetch highlights for this URL asynchronously try { setHighlightsLoading(true) - setHighlights([]) + + // Seed with cached highlights first + if (cachedUrlHighlights.length > 0) { + setHighlights(cachedUrlHighlights.sort((a, b) => b.created_at - a.created_at)) + } else { + setHighlights([]) + } // Check if fetchHighlightsForUrl exists, otherwise skip if (typeof fetchHighlightsForUrl === 'function') { const seen = new Set() + // Seed with cached IDs + cachedUrlHighlights.forEach(h => seen.add(h.id)) + await fetchHighlightsForUrl( relayPool, url, @@ -82,13 +110,11 @@ export function useExternalUrlLoader({ const next = [...prev, highlight] return next.sort((a, b) => b.created_at - a.created_at) }) - } + }, + undefined, // settings + false, // force + eventStore || undefined ) - // Highlights are already set via the streaming callback - // No need to set them again as that could cause a flash/disappearance - console.log(`📌 Finished fetching highlights for URL`) - } else { - console.log('📌 Highlight fetching for URLs not yet implemented') } } catch (err) { console.error('Failed to fetch highlights:', err) @@ -109,6 +135,6 @@ export function useExternalUrlLoader({ } loadExternalUrl() - }, [url, relayPool, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId]) + }, [url, relayPool, eventStore, setSelectedUrl, setReaderContent, setReaderLoading, setIsCollapsed, setHighlights, setHighlightsLoading, setCurrentArticleCoordinate, setCurrentArticleEventId, cachedUrlHighlights]) } diff --git a/src/hooks/useStoreTimeline.ts b/src/hooks/useStoreTimeline.ts new file mode 100644 index 00000000..9cf065e2 --- /dev/null +++ b/src/hooks/useStoreTimeline.ts @@ -0,0 +1,33 @@ +import { useMemo } from 'react' +import { useObservableMemo } from 'applesauce-react/hooks' +import { startWith } from 'rxjs' +import type { IEventStore } from 'applesauce-core' +import type { Filter, NostrEvent } from 'nostr-tools' + +/** + * Subscribe to EventStore timeline and map events to app types + * Provides instant cached results, then updates reactively + * + * @param eventStore - The applesauce event store + * @param filter - Nostr filter to query + * @param mapEvent - Function to transform NostrEvent to app type + * @param deps - Dependencies for memoization + * @returns Array of mapped results + */ +export function useStoreTimeline( + eventStore: IEventStore | null, + filter: Filter, + mapEvent: (event: NostrEvent) => T, + deps: unknown[] = [] +): T[] { + const events = useObservableMemo( + () => eventStore ? eventStore.timeline(filter).pipe(startWith([])) : undefined, + [eventStore, ...deps] + ) + + return useMemo( + () => events?.map(mapEvent) ?? [], + [events, mapEvent] + ) +} + 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() + diff --git a/src/services/highlights/cache.ts b/src/services/highlights/cache.ts new file mode 100644 index 00000000..8ca18af1 --- /dev/null +++ b/src/services/highlights/cache.ts @@ -0,0 +1,96 @@ +import { Highlight } from '../../types/highlights' + +interface CacheEntry { + highlights: Highlight[] + timestamp: number +} + +/** + * Simple in-memory session cache for highlight queries with TTL + */ +class HighlightCache { + private cache = new Map() + private ttlMs = 60000 // 60 seconds + + /** + * Generate cache key for article coordinate + */ + articleKey(coordinate: string): string { + return `article:${coordinate}` + } + + /** + * Generate cache key for URL + */ + urlKey(url: string): string { + // Normalize URL for consistent caching + try { + const normalized = new URL(url) + normalized.hash = '' // Remove hash + return `url:${normalized.toString()}` + } catch { + return `url:${url}` + } + } + + /** + * Generate cache key for author pubkey + */ + authorKey(pubkey: string): string { + return `author:${pubkey}` + } + + /** + * Get cached highlights if not expired + */ + get(key: string): Highlight[] | null { + const entry = this.cache.get(key) + if (!entry) return null + + const now = Date.now() + if (now - entry.timestamp > this.ttlMs) { + this.cache.delete(key) + return null + } + + return entry.highlights + } + + /** + * Store highlights in cache + */ + set(key: string, highlights: Highlight[]): void { + this.cache.set(key, { + highlights, + timestamp: Date.now() + }) + } + + /** + * Clear specific cache entry + */ + clear(key: string): void { + this.cache.delete(key) + } + + /** + * Clear all cache entries + */ + clearAll(): void { + this.cache.clear() + } + + /** + * Get cache stats + */ + stats(): { size: number; keys: string[] } { + return { + size: this.cache.size, + keys: Array.from(this.cache.keys()) + } + } +} + +// Singleton instance +export const highlightCache = new HighlightCache() + diff --git a/src/services/highlights/fetchByAuthor.ts b/src/services/highlights/fetchByAuthor.ts index 011d02eb..5bdc6d56 100644 --- a/src/services/highlights/fetchByAuthor.ts +++ b/src/services/highlights/fetchByAuthor.ts @@ -1,61 +1,77 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' import { KINDS } from '../../config/kinds' +import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlights = async ( relayPool: RelayPool, pubkey: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false, + eventStore?: IEventStore ): Promise => { + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.authorKey(pubkey) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for author (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { - const relayUrls = Array.from(relayPool.relays.values()).map(relay => relay.url) - const ordered = prioritizeLocalRelays(relayUrls) - const { local: localRelays, remote: remoteRelays } = partitionRelays(ordered) - const seenIds = new Set() - const local$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [KINDS.Highlights], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - const remote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [KINDS.Highlights], authors: [pubkey] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - if (!seenIds.has(event.id)) { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - } - }), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) + const rawEvents: NostrEvent[] = await queryEvents( + relayPool, + { kinds: [KINDS.Highlights], authors: [pubkey] }, + { + 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)) + } + } + ) + + 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) { + console.warn('Failed to rebroadcast highlight events:', err) + } - await rebroadcastEvents(rawEvents, relayPool, settings) const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.authorKey(pubkey) + highlightCache.set(cacheKey, sorted) + + return sorted } catch { return [] } diff --git a/src/services/highlights/fetchForArticle.ts b/src/services/highlights/fetchForArticle.ts index 9f5ea08d..e95dde2f 100644 --- a/src/services/highlights/fetchForArticle.ts +++ b/src/services/highlights/fetchForArticle.ts @@ -1,95 +1,81 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' -import { RELAYS } from '../../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' +import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlightsForArticle = async ( relayPool: RelayPool, articleCoordinate: string, eventId?: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false, + eventStore?: IEventStore ): Promise => { + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.articleKey(articleCoordinate) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for article (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { const seenIds = new Set() - const processEvent = (event: NostrEvent): Highlight | null => { - if (seenIds.has(event.id)) return null + const onEvent = (event: NostrEvent) => { + if (seenIds.has(event.id)) return seenIds.add(event.id) - return eventToHighlight(event) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + + if (onHighlight) onHighlight(eventToHighlight(event)) } - const orderedRelays = prioritizeLocalRelays(RELAYS) - const { local: localRelays, remote: remoteRelays } = partitionRelays(orderedRelays) - - const aLocal$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - const aRemote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [9802], '#a': [articleCoordinate] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - const aTagEvents: NostrEvent[] = await lastValueFrom(merge(aLocal$, aRemote$).pipe(toArray())) - - let eTagEvents: NostrEvent[] = [] - if (eventId) { - const eLocal$ = localRelays.length > 0 - ? relayPool - .req(localRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - const eRemote$ = remoteRelays.length > 0 - ? relayPool - .req(remoteRelays, { kinds: [9802], '#e': [eventId] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - const highlight = processEvent(event) - if (highlight && onHighlight) onHighlight(highlight) - }), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - eTagEvents = await lastValueFrom(merge(eLocal$, eRemote$).pipe(toArray())) - } + // Query for both #a and #e tags in parallel + const [aTagEvents, eTagEvents] = await Promise.all([ + queryEvents(relayPool, { kinds: [KINDS.Highlights], '#a': [articleCoordinate] }, { onEvent }), + eventId + ? queryEvents(relayPool, { kinds: [KINDS.Highlights], '#e': [eventId] }, { onEvent }) + : Promise.resolve([] as NostrEvent[]) + ]) const rawEvents = [...aTagEvents, ...eTagEvents] - await rebroadcastEvents(rawEvents, relayPool, settings) + 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) { + console.warn('Failed to rebroadcast highlight events:', err) + } + const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.articleKey(articleCoordinate) + highlightCache.set(cacheKey, sorted) + + return sorted } catch { return [] } diff --git a/src/services/highlights/fetchForUrl.ts b/src/services/highlights/fetchForUrl.ts index 0f94fce7..90e4eff5 100644 --- a/src/services/highlights/fetchForUrl.ts +++ b/src/services/highlights/fetchForUrl.ts @@ -1,68 +1,80 @@ -import { RelayPool, completeOnEose, onlyEvents } from 'applesauce-relay' -import { lastValueFrom, merge, Observable, takeUntil, timer, tap, toArray } from 'rxjs' +import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' +import { IEventStore } from 'applesauce-core' import { Highlight } from '../../types/highlights' -import { RELAYS } from '../../config/relays' -import { prioritizeLocalRelays, partitionRelays } from '../../utils/helpers' +import { KINDS } from '../../config/kinds' import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor' import { UserSettings } from '../settingsService' import { rebroadcastEvents } from '../rebroadcastService' +import { queryEvents } from '../dataFetch' +import { highlightCache } from './cache' export const fetchHighlightsForUrl = async ( relayPool: RelayPool, url: string, onHighlight?: (highlight: Highlight) => void, - settings?: UserSettings + settings?: UserSettings, + force = false, + eventStore?: IEventStore ): Promise => { - const seenIds = new Set() - const orderedRelaysUrl = prioritizeLocalRelays(RELAYS) - const { local: localRelaysUrl, remote: remoteRelaysUrl } = partitionRelays(orderedRelaysUrl) - + // Check cache first unless force refresh + if (!force) { + const cacheKey = highlightCache.urlKey(url) + const cached = highlightCache.get(cacheKey) + if (cached) { + console.log(`📌 Using cached highlights for URL (${cached.length} items)`) + // Stream cached highlights if callback provided + if (onHighlight) { + cached.forEach(h => onHighlight(h)) + } + return cached + } + } try { - const local$ = localRelaysUrl.length > 0 - ? relayPool - .req(localRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - }), - completeOnEose(), - takeUntil(timer(1200)) - ) - : new Observable((sub) => sub.complete()) - const remote$ = remoteRelaysUrl.length > 0 - ? relayPool - .req(remoteRelaysUrl, { kinds: [9802], '#r': [url] }) - .pipe( - onlyEvents(), - tap((event: NostrEvent) => { - seenIds.add(event.id) - if (onHighlight) onHighlight(eventToHighlight(event)) - }), - completeOnEose(), - takeUntil(timer(6000)) - ) - : new Observable((sub) => sub.complete()) - const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray())) - + const seenIds = new Set() + const rawEvents: NostrEvent[] = await queryEvents( + relayPool, + { kinds: [KINDS.Highlights], '#r': [url] }, + { + 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)) + } + } + ) + 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) } catch (err) { console.warn('Failed to rebroadcast highlight events:', err) } - + const uniqueEvents = dedupeHighlights(rawEvents) const highlights: Highlight[] = uniqueEvents.map(eventToHighlight) - return sortHighlights(highlights) + const sorted = sortHighlights(highlights) + + // Cache the results + const cacheKey = highlightCache.urlKey(url) + highlightCache.set(cacheKey, sorted) + + return sorted } catch (err) { console.error('Error fetching highlights for URL:', err) - // Return highlights that were already streamed via callback - // Don't return empty array as that would clear already-displayed highlights return [] } } 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() + diff --git a/src/services/nostrverseService.ts b/src/services/nostrverseService.ts index 586136b5..c1e98841 100644 --- a/src/services/nostrverseService.ts +++ b/src/services/nostrverseService.ts @@ -1,6 +1,6 @@ import { RelayPool } from 'applesauce-relay' import { NostrEvent } from 'nostr-tools' -import { Helpers } from 'applesauce-core' +import { Helpers, IEventStore } from 'applesauce-core' import { BlogPostPreview } from './exploreService' import { Highlight } from '../types/highlights' import { eventToHighlight, dedupeHighlights, sortHighlights } from './highlightEventProcessor' @@ -13,15 +13,17 @@ const { getArticleTitle, getArticleImage, getArticlePublished, getArticleSummary * @param relayPool - The relay pool to query * @param relayUrls - Array of relay URLs to query * @param limit - Maximum number of posts to fetch (default: 50) + * @param eventStore - Optional event store to persist fetched events * @returns Array of blog post previews */ export const fetchNostrverseBlogPosts = async ( relayPool: RelayPool, relayUrls: string[], - limit = 50 + limit = 50, + eventStore?: IEventStore ): Promise => { try { - console.log('📚 Fetching nostrverse blog posts (kind 30023), limit:', limit) + console.log('[NOSTRVERSE] 📚 Fetching blog posts (kind 30023), limit:', limit) // Deduplicate replaceable events by keeping the most recent version const uniqueEvents = new Map() @@ -32,6 +34,11 @@ export const fetchNostrverseBlogPosts = async ( { relayUrls, onEvent: (event: NostrEvent) => { + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + const dTag = event.tags.find(t => t[0] === 'd')?.[1] || '' const key = `${event.pubkey}:${dTag}` const existing = uniqueEvents.get(key) @@ -42,7 +49,7 @@ export const fetchNostrverseBlogPosts = async ( } ) - console.log('📊 Nostrverse blog post events fetched (unique):', uniqueEvents.size) + console.log('[NOSTRVERSE] 📊 Blog post events fetched (unique):', uniqueEvents.size) // Convert to blog post previews and sort by published date (most recent first) const blogPosts: BlogPostPreview[] = Array.from(uniqueEvents.values()) @@ -60,7 +67,7 @@ export const fetchNostrverseBlogPosts = async ( return timeB - timeA // Most recent first }) - console.log('📰 Processed', blogPosts.length, 'unique nostrverse blog posts') + console.log('[NOSTRVERSE] 📰 Processed', blogPosts.length, 'unique blog posts') return blogPosts } catch (error) { @@ -73,25 +80,43 @@ export const fetchNostrverseBlogPosts = async ( * Fetches public highlights (kind:9802) from the nostrverse (not filtered by author) * @param relayPool - The relay pool to query * @param limit - Maximum number of highlights to fetch (default: 100) + * @param eventStore - Optional event store to persist fetched events * @returns Array of highlights */ export const fetchNostrverseHighlights = async ( relayPool: RelayPool, - limit = 100 + limit = 100, + eventStore?: IEventStore ): Promise => { try { - console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit) + console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit) + const seenIds = new Set() const rawEvents = await queryEvents( relayPool, { kinds: [9802], limit }, - {} + { + onEvent: (event: NostrEvent) => { + if (seenIds.has(event.id)) return + seenIds.add(event.id) + + // Store in event store if provided + if (eventStore) { + eventStore.add(event) + } + } + } ) + // Store all events in event store if provided (in case some were missed in streaming) + if (eventStore) { + rawEvents.forEach(evt => eventStore.add(evt)) + } + const uniqueEvents = dedupeHighlights(rawEvents) const highlights = uniqueEvents.map(eventToHighlight) - console.log('💡 Processed', highlights.length, 'unique nostrverse highlights') + console.log('[NOSTRVERSE] 💡 Processed', highlights.length, 'unique highlights') return sortHighlights(highlights) } catch (error) { diff --git a/src/services/settingsService.ts b/src/services/settingsService.ts index a36c7879..54c278a6 100644 --- a/src/services/settingsService.ts +++ b/src/services/settingsService.ts @@ -36,6 +36,10 @@ export interface UserSettings { defaultHighlightVisibilityNostrverse?: boolean defaultHighlightVisibilityFriends?: boolean defaultHighlightVisibilityMine?: boolean + // Default explore scope + defaultExploreScopeNostrverse?: boolean + defaultExploreScopeFriends?: boolean + defaultExploreScopeMine?: boolean // Zap split weights (treated as relative weights, not strict percentages) zapSplitHighlighterWeight?: number // default 50 zapSplitBorisWeight?: number // default 2.1 diff --git a/src/styles/layout/highlights.css b/src/styles/layout/highlights.css index af0bbad9..620473ee 100644 --- a/src/styles/layout/highlights.css +++ b/src/styles/layout/highlights.css @@ -73,7 +73,7 @@ .highlight-mode-toggle .mode-btn.active { background: var(--color-primary); color: rgb(255 255 255); /* white */ } /* Three-level highlight toggles */ -.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; background: rgba(255, 255, 255, 0.05); border-radius: 4px; } +.highlight-level-toggles { display: flex; gap: 0.25rem; padding: 0.25rem; border-radius: 4px; } .highlights-loading, .highlights-empty { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 2rem 1rem; color: var(--color-text-secondary); text-align: center; gap: 0.5rem; } diff --git a/src/utils/dedupe.ts b/src/utils/dedupe.ts new file mode 100644 index 00000000..737fc6d3 --- /dev/null +++ b/src/utils/dedupe.ts @@ -0,0 +1,35 @@ +import { Highlight } from '../types/highlights' +import { BlogPostPreview } from '../services/exploreService' + +/** + * Deduplicate highlights by ID + */ +export function dedupeHighlightsById(highlights: Highlight[]): Highlight[] { + const byId = new Map() + for (const highlight of highlights) { + byId.set(highlight.id, highlight) + } + return Array.from(byId.values()) +} + +/** + * Deduplicate blog posts by replaceable event key (author:d-tag) + * Keeps the newest version when duplicates exist + */ +export function dedupeWritingsByReplaceable(posts: BlogPostPreview[]): BlogPostPreview[] { + const byKey = new Map() + + for (const post of posts) { + const dTag = post.event.tags.find(t => t[0] === 'd')?.[1] || '' + const key = `${post.author}:${dTag}` + const existing = byKey.get(key) + + // Keep the newer version + if (!existing || post.event.created_at > existing.event.created_at) { + byKey.set(key, post) + } + } + + return Array.from(byKey.values()) +} +