mirror of
https://github.com/dergigi/boris.git
synced 2026-01-07 08:54:25 +01:00
Merge pull request #19 from dergigi/loading-improvements-etc
Implement cached-first loading with EventStore
This commit is contained in:
121
CHANGELOG.md
121
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
60
src/App.tsx
60
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<Bookmark[]>([])
|
||||
const [bookmarksLoading, setBookmarksLoading] = useState(false)
|
||||
|
||||
// Centralized contacts state (fed by controller)
|
||||
const [contacts, setContacts] = useState<Set<string>>(new Set())
|
||||
const [contactsLoading, setContactsLoading] = useState(false)
|
||||
|
||||
// Subscribe to bookmark controller
|
||||
useEffect(() => {
|
||||
console.log('[bookmark] 🎧 Subscribing to bookmark controller')
|
||||
@@ -59,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={
|
||||
<Debug
|
||||
relayPool={relayPool}
|
||||
eventStore={eventStore}
|
||||
bookmarks={bookmarks}
|
||||
bookmarksLoading={bookmarksLoading}
|
||||
onRefreshBookmarks={handleRefreshBookmarks}
|
||||
@@ -639,7 +687,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>
|
||||
|
||||
@@ -35,7 +35,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
onLogout,
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks
|
||||
onRefreshBookmarks
|
||||
}) => {
|
||||
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
|
||||
const location = useLocation()
|
||||
@@ -179,6 +179,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
})
|
||||
|
||||
@@ -242,6 +243,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
useExternalUrlLoader({
|
||||
url: externalUrl,
|
||||
relayPool,
|
||||
eventStore,
|
||||
setSelectedUrl,
|
||||
setReaderContent,
|
||||
setReaderLoading,
|
||||
@@ -325,10 +327,10 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
||||
relayPool ? <Explore relayPool={relayPool} eventStore={eventStore} settings={settings} activeTab={exploreTab} /> : null
|
||||
) : undefined}
|
||||
me={showMe ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={meTab} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
profile={showProfile && profilePubkey ? (
|
||||
relayPool ? <Me relayPool={relayPool} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
relayPool ? <Me relayPool={relayPool} eventStore={eventStore} activeTab={profileTab} pubkey={profilePubkey} bookmarks={bookmarks} bookmarksLoading={bookmarksLoading} /> : null
|
||||
) : undefined}
|
||||
support={showSupport ? (
|
||||
relayPool ? <Support relayPool={relayPool} eventStore={eventStore} settings={settings} /> : null
|
||||
|
||||
@@ -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<void>
|
||||
@@ -29,6 +32,7 @@ interface DebugProps {
|
||||
|
||||
const Debug: React.FC<DebugProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
bookmarks,
|
||||
bookmarksLoading,
|
||||
onRefreshBookmarks,
|
||||
@@ -37,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
|
||||
})
|
||||
@@ -76,17 +79,33 @@ const Debug: React.FC<DebugProps> = ({
|
||||
const [bookmarkStats, setBookmarkStats] = useState<{ public: number; private: number } | null>(null)
|
||||
const [tLoadBookmarks, setTLoadBookmarks] = useState<number | null>(null)
|
||||
const [tDecryptBookmarks, setTDecryptBookmarks] = useState<number | null>(null)
|
||||
const [tFirstBookmark, setTFirstBookmark] = useState<number | null>(null)
|
||||
|
||||
// Individual event decryption results
|
||||
const [decryptedEvents, setDecryptedEvents] = useState<Map<string, { public: number; private: number }>>(new Map())
|
||||
|
||||
// Highlight loading state
|
||||
const [highlightMode, setHighlightMode] = useState<'article' | 'url' | 'author'>('author')
|
||||
const [highlightArticleCoord, setHighlightArticleCoord] = useState<string>('')
|
||||
const [highlightUrl, setHighlightUrl] = useState<string>('')
|
||||
const [highlightAuthor, setHighlightAuthor] = useState<string>('')
|
||||
const [isLoadingHighlights, setIsLoadingHighlights] = useState(false)
|
||||
const [highlightEvents, setHighlightEvents] = useState<NostrEvent[]>([])
|
||||
const [tLoadHighlights, setTLoadHighlights] = useState<number | null>(null)
|
||||
const [tFirstHighlight, setTFirstHighlight] = useState<number | null>(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<Set<string>>(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<DebugProps> = ({
|
||||
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<DebugProps> = ({
|
||||
|
||||
// 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<DebugProps> = ({
|
||||
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<string>()
|
||||
|
||||
// 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<string>()
|
||||
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<DebugProps> = ({
|
||||
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<DebugProps> = ({
|
||||
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<DebugProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="load" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
|
||||
<Stat label="total" value={tLoadBookmarks} bookmarkOp="loadBookmarks" />
|
||||
<Stat label="first event" value={tFirstBookmark} />
|
||||
<Stat label="decrypt" value={tDecryptBookmarks} bookmarkOp="decryptBookmarks" />
|
||||
</div>
|
||||
|
||||
@@ -647,6 +908,204 @@ const Debug: React.FC<DebugProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Highlight Loading Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Highlight Loading</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Test highlight loading with EOSE-based queryEvents (kind: 9802). Author mode defaults to your highlights.</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Query Mode:</div>
|
||||
<div className="flex gap-2">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={highlightMode === 'article'}
|
||||
onChange={() => setHighlightMode('article')}
|
||||
/>
|
||||
<span>Article (#a)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={highlightMode === 'url'}
|
||||
onChange={() => setHighlightMode('url')}
|
||||
/>
|
||||
<span>URL (#r)</span>
|
||||
</label>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="radio"
|
||||
checked={highlightMode === 'author'}
|
||||
onChange={() => setHighlightMode('author')}
|
||||
/>
|
||||
<span>Author</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-3">
|
||||
{highlightMode === 'article' && (
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
placeholder="30023:pubkey:identifier"
|
||||
value={highlightArticleCoord}
|
||||
onChange={(e) => setHighlightArticleCoord(e.target.value)}
|
||||
disabled={isLoadingHighlights}
|
||||
/>
|
||||
)}
|
||||
{highlightMode === 'url' && (
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
placeholder="https://example.com/article"
|
||||
value={highlightUrl}
|
||||
onChange={(e) => setHighlightUrl(e.target.value)}
|
||||
disabled={isLoadingHighlights}
|
||||
/>
|
||||
)}
|
||||
{highlightMode === 'author' && (
|
||||
<input
|
||||
type="text"
|
||||
className="input w-full"
|
||||
placeholder={pubkey ? `${pubkey.slice(0, 16)}... (logged-in user)` : 'pubkey (hex)'}
|
||||
value={highlightAuthor}
|
||||
onChange={(e) => setHighlightAuthor(e.target.value)}
|
||||
disabled={isLoadingHighlights}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3 items-center">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadHighlights}
|
||||
disabled={isLoadingHighlights || !relayPool}
|
||||
>
|
||||
{isLoadingHighlights ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Highlights'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary ml-auto"
|
||||
onClick={handleClearHighlights}
|
||||
disabled={highlightEvents.length === 0}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 text-sm opacity-70">Quick load options:</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadMyHighlights}
|
||||
disabled={isLoadingHighlights || !relayPool || !activeAccount}
|
||||
>
|
||||
Load My Highlights
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadFriendsHighlights}
|
||||
disabled={isLoadingHighlights || !relayPool || !activeAccount}
|
||||
>
|
||||
Load Friends Highlights
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-secondary text-sm"
|
||||
onClick={handleLoadNostrverseHighlights}
|
||||
disabled={isLoadingHighlights || !relayPool}
|
||||
>
|
||||
Load Nostrverse Highlights
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 flex gap-2 flex-wrap">
|
||||
<Stat label="total" value={tLoadHighlights} bookmarkOp="loadHighlights" />
|
||||
<Stat label="first event" value={tFirstHighlight} />
|
||||
</div>
|
||||
|
||||
{highlightEvents.length > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Loaded Highlights ({highlightEvents.length}):</div>
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{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 (
|
||||
<div key={idx} className="font-mono text-xs p-2 bg-gray-100 dark:bg-gray-800 rounded">
|
||||
<div className="font-semibold mb-1">Highlight #{idx + 1}</div>
|
||||
<div className="opacity-70 mb-1">
|
||||
<div>Author: {evt.pubkey.slice(0, 16)}...</div>
|
||||
<div>Created: {new Date(evt.created_at * 1000).toLocaleString()}</div>
|
||||
</div>
|
||||
<div className="mt-1">
|
||||
<div className="font-semibold text-[11px]">Content:</div>
|
||||
<div className="italic">"{shortContent}"</div>
|
||||
</div>
|
||||
{contextTag && (
|
||||
<div className="mt-1 text-[11px] opacity-70">
|
||||
<div>Context: {contextTag.substring(0, 60)}...</div>
|
||||
</div>
|
||||
)}
|
||||
{aTag && <div className="mt-1 text-[11px] opacity-70">#a: {aTag}</div>}
|
||||
{rTag && <div className="mt-1 text-[11px] opacity-70">#r: {rTag}</div>}
|
||||
{eTag && <div className="mt-1 text-[11px] opacity-70">#e: {eTag.slice(0, 16)}...</div>}
|
||||
<div className="opacity-50 mt-1 text-[10px] break-all">ID: {evt.id}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Web of Trust Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Web of Trust</h3>
|
||||
<div className="text-sm opacity-70 mb-3">Load your followed contacts (friends) for highlight fetching:</div>
|
||||
|
||||
<div className="mb-3">
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={handleLoadFriendsList}
|
||||
disabled={friendsButtonLoading || !relayPool || !activeAccount}
|
||||
>
|
||||
{friendsButtonLoading ? (
|
||||
<>
|
||||
<FontAwesomeIcon icon={faSpinner} className="animate-spin mr-2" />
|
||||
Loading...
|
||||
</>
|
||||
) : (
|
||||
'Load Friends'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{friendsPubkeys.size > 0 && (
|
||||
<div className="mb-3">
|
||||
<div className="text-sm opacity-70 mb-2">Friends Count: {friendsNpubs.length}</div>
|
||||
<div className="font-mono text-xs max-h-48 overflow-y-auto bg-gray-100 dark:bg-gray-800 p-3 rounded space-y-1">
|
||||
{friendsNpubs.map(npub => (
|
||||
<div key={npub} title={npub} className="truncate hover:text-clip hover:whitespace-normal cursor-pointer">
|
||||
{npub}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Debug Logs Section */}
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Debug Logs</h3>
|
||||
|
||||
@@ -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<ExploreProps> = ({ 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<Highlight[]>([])
|
||||
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<HighlightVisibility>({
|
||||
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<ExploreProps> = ({ 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<ExploreProps> = ({ 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<ExploreProps> = ({ 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<string, BlogPostPreview>()
|
||||
|
||||
// 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<ExploreProps> = ({ 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<string, BlogPostPreview>()
|
||||
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<string, Highlight>()
|
||||
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<ExploreProps> = ({ 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<ExploreProps> = ({ 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 (
|
||||
<div className="explore-container">
|
||||
@@ -422,7 +506,9 @@ const Explore: React.FC<ExploreProps> = ({ relayPool, eventStore, settings, acti
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{renderTabContent()}
|
||||
<div key={activeTab}>
|
||||
{renderTabContent()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<MeProps> = ({
|
||||
relayPool,
|
||||
eventStore,
|
||||
activeTab: propActiveTab,
|
||||
pubkey: propPubkey,
|
||||
bookmarks
|
||||
@@ -67,6 +76,34 @@ const Me: React.FC<MeProps> = ({
|
||||
const [writings, setWritings] = useState<BlogPostPreview[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loadedTabs, setLoadedTabs] = useState<Set<TabType>>(new Set())
|
||||
|
||||
// Get myHighlights directly from controller
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
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<ViewMode>('cards')
|
||||
const [refreshTrigger, setRefreshTrigger] = useState(0)
|
||||
const [bookmarkFilter, setBookmarkFilter] = useState<BookmarkFilterType>('all')
|
||||
@@ -87,6 +124,20 @@ const Me: React.FC<MeProps> = ({
|
||||
: 'all'
|
||||
const [readingProgressFilter, setReadingProgressFilter] = useState<ReadingProgressFilterType>(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<MeProps> = ({
|
||||
|
||||
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<MeProps> = ({
|
||||
|
||||
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<MeProps> = ({
|
||||
// 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<MeProps> = ({
|
||||
|
||||
// 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<MeProps> = ({
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return highlights.length === 0 && !loading ? (
|
||||
return highlights.length === 0 && !loading && !(isOwnProfile && myHighlightsLoading) ? (
|
||||
<div className="explore-loading" style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', padding: '4rem', color: 'var(--text-secondary)' }}>
|
||||
No highlights yet.
|
||||
</div>
|
||||
|
||||
@@ -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<SettingsProps> = ({ settings, onSave, onClose, relayPoo
|
||||
<div className="settings-content">
|
||||
<ThemeSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ReadingDisplaySettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ExploreSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<ZapSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<LayoutBehaviorSettings settings={localSettings} onUpdate={handleUpdate} />
|
||||
<PWASettings settings={localSettings} onUpdate={handleUpdate} onClose={onClose} />
|
||||
|
||||
59
src/components/Settings/ExploreSettings.tsx
Normal file
59
src/components/Settings/ExploreSettings.tsx
Normal file
@@ -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<UserSettings>) => void
|
||||
}
|
||||
|
||||
const ExploreSettings: React.FC<ExploreSettingsProps> = ({ settings, onUpdate }) => {
|
||||
return (
|
||||
<div className="settings-section">
|
||||
<h3 className="section-title">Explore</h3>
|
||||
|
||||
<div className="setting-group setting-inline">
|
||||
<label>Default Explore Scope</label>
|
||||
<div className="highlight-level-toggles">
|
||||
<IconButton
|
||||
icon={faNetworkWired}
|
||||
onClick={() => 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
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUserGroup}
|
||||
onClick={() => 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
|
||||
}}
|
||||
/>
|
||||
<IconButton
|
||||
icon={faUser}
|
||||
onClick={() => 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
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ExploreSettings
|
||||
|
||||
@@ -414,7 +414,7 @@ const ThreePaneLayout: React.FC<ThreePaneLayoutProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{props.hasActiveAccount && (
|
||||
{props.hasActiveAccount && props.readerContent && (
|
||||
<HighlightButton
|
||||
ref={props.highlightButtonRef}
|
||||
onHighlight={props.onCreateHighlight}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { IAccount } from 'applesauce-accounts'
|
||||
import { IEventStore } from 'applesauce-core'
|
||||
import { Bookmark } from '../types/bookmarks'
|
||||
import { Highlight } from '../types/highlights'
|
||||
import { fetchHighlights, fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { fetchContacts } from '../services/contactService'
|
||||
import { fetchHighlightsForArticle } from '../services/highlightService'
|
||||
import { UserSettings } from '../services/settingsService'
|
||||
import { highlightsController } from '../services/highlightsController'
|
||||
import { contactsController } from '../services/contactsController'
|
||||
import { useStoreTimeline } from './useStoreTimeline'
|
||||
import { eventToHighlight } from '../services/highlightEventProcessor'
|
||||
import { KINDS } from '../config/kinds'
|
||||
|
||||
interface UseBookmarksDataParams {
|
||||
relayPool: RelayPool | null
|
||||
@@ -15,6 +20,7 @@ interface UseBookmarksDataParams {
|
||||
currentArticleCoordinate?: string
|
||||
currentArticleEventId?: string
|
||||
settings?: UserSettings
|
||||
eventStore?: IEventStore | null
|
||||
bookmarks: Bookmark[] // Passed from App.tsx (centralized loading)
|
||||
bookmarksLoading: boolean // Passed from App.tsx (centralized loading)
|
||||
onRefreshBookmarks: () => Promise<void>
|
||||
@@ -28,19 +34,50 @@ export const useBookmarksData = ({
|
||||
currentArticleCoordinate,
|
||||
currentArticleEventId,
|
||||
settings,
|
||||
eventStore,
|
||||
onRefreshBookmarks
|
||||
}: Omit<UseBookmarksDataParams, 'bookmarks' | 'bookmarksLoading'>) => {
|
||||
const [highlights, setHighlights] = useState<Highlight[]>([])
|
||||
const [myHighlights, setMyHighlights] = useState<Highlight[]>([])
|
||||
const [articleHighlights, setArticleHighlights] = useState<Highlight[]>([])
|
||||
const [highlightsLoading, setHighlightsLoading] = useState(true)
|
||||
const [followedPubkeys, setFollowedPubkeys] = useState<Set<string>>(new Set())
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [lastFetchTime, setLastFetchTime] = useState<number | null>(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<string, Highlight>()
|
||||
// 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,
|
||||
|
||||
@@ -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<string>()
|
||||
// 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])
|
||||
}
|
||||
|
||||
|
||||
33
src/hooks/useStoreTimeline.ts
Normal file
33
src/hooks/useStoreTimeline.ts
Normal file
@@ -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<T>(
|
||||
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]
|
||||
)
|
||||
}
|
||||
|
||||
114
src/services/contactsController.ts
Normal file
114
src/services/contactsController.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { RelayPool } from 'applesauce-relay'
|
||||
import { fetchContacts } from './contactService'
|
||||
|
||||
type ContactsCallback = (contacts: Set<string>) => void
|
||||
type LoadingCallback = (loading: boolean) => void
|
||||
|
||||
/**
|
||||
* Shared contacts/friends controller
|
||||
* Manages the user's follow list centrally, similar to bookmarkController
|
||||
*/
|
||||
class ContactsController {
|
||||
private contactsListeners: ContactsCallback[] = []
|
||||
private loadingListeners: LoadingCallback[] = []
|
||||
|
||||
private currentContacts: Set<string> = new Set()
|
||||
private lastLoadedPubkey: string | null = null
|
||||
|
||||
onContacts(cb: ContactsCallback): () => void {
|
||||
this.contactsListeners.push(cb)
|
||||
return () => {
|
||||
this.contactsListeners = this.contactsListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
onLoading(cb: LoadingCallback): () => void {
|
||||
this.loadingListeners.push(cb)
|
||||
return () => {
|
||||
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
|
||||
}
|
||||
}
|
||||
|
||||
private setLoading(loading: boolean): void {
|
||||
this.loadingListeners.forEach(cb => cb(loading))
|
||||
}
|
||||
|
||||
private emitContacts(contacts: Set<string>): void {
|
||||
this.contactsListeners.forEach(cb => cb(contacts))
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current contacts without triggering a reload
|
||||
*/
|
||||
getContacts(): Set<string> {
|
||||
return new Set(this.currentContacts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if contacts are loaded for a specific pubkey
|
||||
*/
|
||||
isLoadedFor(pubkey: string): boolean {
|
||||
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state (for logout or manual refresh)
|
||||
*/
|
||||
reset(): void {
|
||||
this.currentContacts.clear()
|
||||
this.lastLoadedPubkey = null
|
||||
this.emitContacts(this.currentContacts)
|
||||
}
|
||||
|
||||
/**
|
||||
* Load contacts for a user
|
||||
* Streams partial results and caches the final list
|
||||
*/
|
||||
async start(options: {
|
||||
relayPool: RelayPool
|
||||
pubkey: string
|
||||
force?: boolean
|
||||
}): Promise<void> {
|
||||
const { relayPool, pubkey, force = false } = options
|
||||
|
||||
// Skip if already loaded for this pubkey (unless forced)
|
||||
if (!force && this.isLoadedFor(pubkey)) {
|
||||
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
|
||||
this.emitContacts(this.currentContacts)
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
|
||||
|
||||
try {
|
||||
const contacts = await fetchContacts(
|
||||
relayPool,
|
||||
pubkey,
|
||||
(partial) => {
|
||||
// Stream partial updates
|
||||
this.currentContacts = new Set(partial)
|
||||
this.emitContacts(this.currentContacts)
|
||||
console.log('[contacts] 📥 Partial contacts:', partial.size)
|
||||
}
|
||||
)
|
||||
|
||||
// Store final result
|
||||
this.currentContacts = new Set(contacts)
|
||||
this.lastLoadedPubkey = pubkey
|
||||
this.emitContacts(this.currentContacts)
|
||||
|
||||
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
|
||||
} catch (error) {
|
||||
console.error('[contacts] ❌ Failed to load contacts:', error)
|
||||
this.currentContacts.clear()
|
||||
this.emitContacts(this.currentContacts)
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const contactsController = new ContactsController()
|
||||
|
||||
96
src/services/highlights/cache.ts
Normal file
96
src/services/highlights/cache.ts
Normal file
@@ -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<string, CacheEntry>()
|
||||
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()
|
||||
|
||||
@@ -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<Highlight[]> => {
|
||||
// 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<string>()
|
||||
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<NostrEvent>((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<NostrEvent>((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 []
|
||||
}
|
||||
|
||||
@@ -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<Highlight[]> => {
|
||||
// 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<string>()
|
||||
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<NostrEvent>((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<NostrEvent>((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<NostrEvent>((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<NostrEvent>((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 []
|
||||
}
|
||||
|
||||
@@ -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<Highlight[]> => {
|
||||
const seenIds = new Set<string>()
|
||||
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<NostrEvent>((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<NostrEvent>((sub) => sub.complete())
|
||||
const rawEvents: NostrEvent[] = await lastValueFrom(merge(local$, remote$).pipe(toArray()))
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
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 []
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
208
src/services/highlightsController.ts
Normal file
208
src/services/highlightsController.ts
Normal 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()
|
||||
|
||||
@@ -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<BlogPostPreview[]> => {
|
||||
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<string, NostrEvent>()
|
||||
@@ -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<Highlight[]> => {
|
||||
try {
|
||||
console.log('💡 Fetching nostrverse highlights (kind 9802), limit:', limit)
|
||||
console.log('[NOSTRVERSE] 💡 Fetching highlights (kind 9802), limit:', limit)
|
||||
|
||||
const seenIds = new Set<string>()
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
35
src/utils/dedupe.ts
Normal file
35
src/utils/dedupe.ts
Normal file
@@ -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<string, Highlight>()
|
||||
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<string, BlogPostPreview>()
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user