Merge pull request #19 from dergigi/loading-improvements-etc

Implement cached-first loading with EventStore
This commit is contained in:
Gigi
2025-10-18 23:30:46 +02:00
committed by GitHub
24 changed files with 1745 additions and 263 deletions

View File

@@ -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

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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">&quot;{shortContent}&quot;</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>

View File

@@ -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>
)
}

View File

@@ -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>

View File

@@ -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} />

View 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

View File

@@ -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}

View File

@@ -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,

View File

@@ -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])
}

View 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]
)
}

View File

@@ -0,0 +1,114 @@
import { RelayPool } from 'applesauce-relay'
import { fetchContacts } from './contactService'
type ContactsCallback = (contacts: Set<string>) => void
type LoadingCallback = (loading: boolean) => void
/**
* Shared contacts/friends controller
* Manages the user's follow list centrally, similar to bookmarkController
*/
class ContactsController {
private contactsListeners: ContactsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentContacts: Set<string> = new Set()
private lastLoadedPubkey: string | null = null
onContacts(cb: ContactsCallback): () => void {
this.contactsListeners.push(cb)
return () => {
this.contactsListeners = this.contactsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitContacts(contacts: Set<string>): void {
this.contactsListeners.forEach(cb => cb(contacts))
}
/**
* Get current contacts without triggering a reload
*/
getContacts(): Set<string> {
return new Set(this.currentContacts)
}
/**
* Check if contacts are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentContacts.size > 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.currentContacts.clear()
this.lastLoadedPubkey = null
this.emitContacts(this.currentContacts)
}
/**
* Load contacts for a user
* Streams partial results and caches the final list
*/
async start(options: {
relayPool: RelayPool
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[contacts] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitContacts(this.currentContacts)
return
}
this.setLoading(true)
console.log('[contacts] 🔍 Loading contacts for', pubkey.slice(0, 8))
try {
const contacts = await fetchContacts(
relayPool,
pubkey,
(partial) => {
// Stream partial updates
this.currentContacts = new Set(partial)
this.emitContacts(this.currentContacts)
console.log('[contacts] 📥 Partial contacts:', partial.size)
}
)
// Store final result
this.currentContacts = new Set(contacts)
this.lastLoadedPubkey = pubkey
this.emitContacts(this.currentContacts)
console.log('[contacts] ✅ Loaded', contacts.size, 'contacts')
} catch (error) {
console.error('[contacts] ❌ Failed to load contacts:', error)
this.currentContacts.clear()
this.emitContacts(this.currentContacts)
} finally {
this.setLoading(false)
}
}
}
// Singleton instance
export const contactsController = new ContactsController()

View 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()

View File

@@ -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 []
}

View File

@@ -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 []
}

View File

@@ -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 []
}
}

View File

@@ -1,5 +1,6 @@
import { RelayPool } from 'applesauce-relay'
import { NostrEvent } from 'nostr-tools'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../../types/highlights'
import { eventToHighlight, dedupeHighlights, sortHighlights } from '../highlightEventProcessor'
import { queryEvents } from '../dataFetch'
@@ -9,12 +10,14 @@ import { queryEvents } from '../dataFetch'
* @param relayPool - The relay pool to query
* @param pubkeys - Array of pubkeys to fetch highlights from
* @param onHighlight - Optional callback for streaming highlights as they arrive
* @param eventStore - Optional event store to persist events
* @returns Array of highlights
*/
export const fetchHighlightsFromAuthors = async (
relayPool: RelayPool,
pubkeys: string[],
onHighlight?: (highlight: Highlight) => void
onHighlight?: (highlight: Highlight) => void,
eventStore?: IEventStore
): Promise<Highlight[]> => {
try {
if (pubkeys.length === 0) {
@@ -32,12 +35,23 @@ export const fetchHighlightsFromAuthors = async (
onEvent: (event: NostrEvent) => {
if (!seenIds.has(event.id)) {
seenIds.add(event.id)
// Store in event store if provided
if (eventStore) {
eventStore.add(event)
}
if (onHighlight) onHighlight(eventToHighlight(event))
}
}
}
)
// Store all events in event store if provided
if (eventStore) {
rawEvents.forEach(evt => eventStore.add(evt))
}
const uniqueEvents = dedupeHighlights(rawEvents)
const highlights = uniqueEvents.map(eventToHighlight)

View File

@@ -0,0 +1,208 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { Highlight } from '../types/highlights'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { eventToHighlight, sortHighlights } from './highlightEventProcessor'
type HighlightsCallback = (highlights: Highlight[]) => void
type LoadingCallback = (loading: boolean) => void
const LAST_SYNCED_KEY = 'highlights_last_synced'
/**
* Shared highlights controller
* Manages the user's highlights centrally, similar to bookmarkController
*/
class HighlightsController {
private highlightsListeners: HighlightsCallback[] = []
private loadingListeners: LoadingCallback[] = []
private currentHighlights: Highlight[] = []
private lastLoadedPubkey: string | null = null
private generation = 0
onHighlights(cb: HighlightsCallback): () => void {
this.highlightsListeners.push(cb)
return () => {
this.highlightsListeners = this.highlightsListeners.filter(l => l !== cb)
}
}
onLoading(cb: LoadingCallback): () => void {
this.loadingListeners.push(cb)
return () => {
this.loadingListeners = this.loadingListeners.filter(l => l !== cb)
}
}
private setLoading(loading: boolean): void {
this.loadingListeners.forEach(cb => cb(loading))
}
private emitHighlights(highlights: Highlight[]): void {
this.highlightsListeners.forEach(cb => cb(highlights))
}
/**
* Get current highlights without triggering a reload
*/
getHighlights(): Highlight[] {
return [...this.currentHighlights]
}
/**
* Check if highlights are loaded for a specific pubkey
*/
isLoadedFor(pubkey: string): boolean {
return this.lastLoadedPubkey === pubkey && this.currentHighlights.length >= 0
}
/**
* Reset state (for logout or manual refresh)
*/
reset(): void {
this.generation++
this.currentHighlights = []
this.lastLoadedPubkey = null
this.emitHighlights(this.currentHighlights)
}
/**
* Get last synced timestamp for incremental loading
*/
private getLastSyncedAt(pubkey: string): number | null {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
if (!data) return null
const parsed = JSON.parse(data)
return parsed[pubkey] || null
} catch {
return null
}
}
/**
* Update last synced timestamp
*/
private setLastSyncedAt(pubkey: string, timestamp: number): void {
try {
const data = localStorage.getItem(LAST_SYNCED_KEY)
const parsed = data ? JSON.parse(data) : {}
parsed[pubkey] = timestamp
localStorage.setItem(LAST_SYNCED_KEY, JSON.stringify(parsed))
} catch (err) {
console.warn('[highlights] Failed to save last synced timestamp:', err)
}
}
/**
* Load highlights for a user
* Streams results and stores in event store
*/
async start(options: {
relayPool: RelayPool
eventStore: IEventStore
pubkey: string
force?: boolean
}): Promise<void> {
const { relayPool, eventStore, pubkey, force = false } = options
// Skip if already loaded for this pubkey (unless forced)
if (!force && this.isLoadedFor(pubkey)) {
console.log('[highlights] ✅ Already loaded for', pubkey.slice(0, 8))
this.emitHighlights(this.currentHighlights)
return
}
// Increment generation to cancel any in-flight work
this.generation++
const currentGeneration = this.generation
this.setLoading(true)
console.log('[highlights] 🔍 Loading highlights for', pubkey.slice(0, 8))
try {
const seenIds = new Set<string>()
const highlightsMap = new Map<string, Highlight>()
// Get last synced timestamp for incremental loading
const lastSyncedAt = force ? null : this.getLastSyncedAt(pubkey)
const filter: { kinds: number[]; authors: string[]; since?: number } = {
kinds: [KINDS.Highlights],
authors: [pubkey]
}
if (lastSyncedAt) {
filter.since = lastSyncedAt
console.log('[highlights] 📅 Incremental sync since', new Date(lastSyncedAt * 1000).toISOString())
}
const events = await queryEvents(
relayPool,
filter,
{
onEvent: (evt) => {
// Check if this generation is still active
if (currentGeneration !== this.generation) return
if (seenIds.has(evt.id)) return
seenIds.add(evt.id)
// Store in event store immediately
eventStore.add(evt)
// Convert to highlight and add to map
const highlight = eventToHighlight(evt)
highlightsMap.set(highlight.id, highlight)
// Stream to listeners
const sortedHighlights = sortHighlights(Array.from(highlightsMap.values()))
this.currentHighlights = sortedHighlights
this.emitHighlights(sortedHighlights)
}
}
)
// Check if still active after async operation
if (currentGeneration !== this.generation) {
console.log('[highlights] ⚠️ Load cancelled (generation mismatch)')
return
}
// Store all events in event store
events.forEach(evt => eventStore.add(evt))
// Final processing
const highlights = events.map(eventToHighlight)
const uniqueHighlights = Array.from(
new Map(highlights.map(h => [h.id, h])).values()
)
const sorted = sortHighlights(uniqueHighlights)
this.currentHighlights = sorted
this.lastLoadedPubkey = pubkey
this.emitHighlights(sorted)
// Update last synced timestamp
if (sorted.length > 0) {
const newestTimestamp = Math.max(...sorted.map(h => h.created_at))
this.setLastSyncedAt(pubkey, newestTimestamp)
}
console.log('[highlights] ✅ Loaded', sorted.length, 'highlights')
} catch (error) {
console.error('[highlights] ❌ Failed to load highlights:', error)
this.currentHighlights = []
this.emitHighlights(this.currentHighlights)
} finally {
// Only clear loading if this generation is still active
if (currentGeneration === this.generation) {
this.setLoading(false)
}
}
}
}
// Singleton instance
export const highlightsController = new HighlightsController()

View File

@@ -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) {

View File

@@ -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

View File

@@ -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
View 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())
}