Compare commits

...

44 Commits

Author SHA1 Message Date
Gigi
366e10b23a feat(/e/): check eventStore first for author profile
- Try to load author profile from eventStore cache first
- Only fetch from relays if not found in cache
- Instant title update if profile already loaded
2025-10-22 01:19:09 +02:00
Gigi
bb66823915 fix(/e/): Search button opens note via /e/ path not search portal
- For kind:1 notes, open directly via /e/{eventId}
- For articles (kind:30023), continue using search portal
- Removes nostr-event: prefix in URLs
2025-10-22 01:18:51 +02:00
Gigi
f09973c858 feat(/e/): display publication date in top-right like articles
- Remove inline metadata HTML from note content
- Pass event.created_at as published timestamp via ReadableContent
- ReaderHeader now displays date in top-right corner
2025-10-22 01:18:14 +02:00
Gigi
d03726801d feat(/e/): title 'Note by @author' with background profile fetch
- Immediate fallback title using short pubkey
- Fetch kind:0 profile in background; update title when available
- Keeps UI responsive while improving attribution
2025-10-22 01:16:30 +02:00
Gigi
164e941a1f fix(events): make direct event loading robust
- Add completion and timeout handling to eventManager.fetchEvent
- Resolve/reject all pending promises correctly
- Prevent silent completes when event not found
- Improves /e/:eventId reliability on cold loads
2025-10-22 01:09:36 +02:00
Gigi
6def58f128 fix(bookmarks): show eventStore content as fallback for bookmarks without hydrated content
- Enrich bookmarks with content from externalEventStore when hydration hasn't populated yet
- Keeps sidebar from showing only event IDs while background hydration continues
2025-10-22 01:04:23 +02:00
Gigi
347e23ff6f fix: only request hydration for items without content
- Only fetch events for bookmarks that don't have content yet
- Bookmarks with existing content (web bookmarks, etc.) don't need fetching
- This reduces unnecessary fetches and focuses on what's needed
- Should show much better content in bookmarks list
2025-10-22 01:01:23 +02:00
Gigi
934768ebf2 chore: remove debug logging from hydration 2025-10-22 01:01:04 +02:00
Gigi
60e9ede9cf debug: add more detail to hydration logging 2025-10-22 00:59:06 +02:00
Gigi
c70e6bc2aa debug: log hydration progress to track content population
- Add logging to see how many hydrated items have content
- This will help diagnose why bookmarks are showing IDs instead of content
2025-10-22 00:57:47 +02:00
Gigi
ab8665815b chore: remove debug logging from bookmarkHelpers
- Remove 'NO MATCHES' debug logs from hydrateItems
- Console is now clean, hydration is working properly
2025-10-22 00:56:40 +02:00
Gigi
1929b50892 fix: properly implement eventManager with promise-based API
- Fix eventManager to handle async fetching with proper promise resolution
- Track pending requests and deduplicate concurrent requests for same event
- Auto-retry when relay pool becomes available
- Resolve all pending callbacks when event arrives
- Update useEventLoader to use eventManager.fetchEvent
- Simplify useEventLoader with just one effect for fetching
- Handles both instant cache hits and deferred relay fetching
2025-10-22 00:55:20 +02:00
Gigi
160dca628d fix: simplify eventManager and restore working event fetching
- Revert eventManager to simpler role: initialization and service coordination
- Restore original working fetching logic in useEventLoader
- eventManager now provides: getCachedEvent, getEventLoader, setServices
- Fixes broken bookmark hydration and direct event loading
- Uses eventManager for cache checking but direct subscription for fetching
2025-10-22 00:54:33 +02:00
Gigi
c04ba0c787 feat: add centralized eventManager for event fetching
- Create eventManager singleton for fetching and caching events
- Handles deduplication of concurrent requests for same event
- Waits for relay pool to become available before fetching
- Provides both async/await and callback-based APIs
- Update useEventLoader to use eventManager instead of direct loader
- Simplifies event fetching logic and enables better reuse across app
2025-10-22 00:52:15 +02:00
Gigi
479d9314bd fix: make event loading non-blocking and wait for relay pool
- Don't show error if relayPool isn't available yet
- Instead, keep loading state and wait for relayPool to become available
- Effect will re-run automatically when relayPool is set
- Enables smooth loading when navigating directly to /e/ URLs on page load
- Fetching happens in background without blocking user
2025-10-22 00:50:14 +02:00
Gigi
b9d5e501f4 improve: better error messages when direct event loading fails
- Show error if relayPool is not available when loading direct URL
- Improved error message wording to be clearer
- These messages will help diagnose direct /e/ path loading issues
2025-10-22 00:49:50 +02:00
Gigi
43e0dd76c4 fix: don't show user highlights when viewing events on /e/ path
- Set selectedUrl and ReadableContent url to empty string for events
- This prevents ThreePaneLayout from displaying user highlights for event views
- Events should only show event-specific content, not global user highlights
- Fixes issue where 422 highlights were always shown for all notes
2025-10-22 00:48:43 +02:00
Gigi
dc9a49e895 chore: remove debug logging from event loader and compact view
- Remove debug logs from useEventLoader hook
- Remove debug logs from Bookmarks component
- Remove empty kind:1 bookmark debug logging from CompactView
- Clean console output now that features are working correctly
2025-10-22 00:46:44 +02:00
Gigi
3200bdf378 fix: add hydrated bookmark events to global eventStore
- bookmarkController now accepts eventStore in start() options
- All hydrated events (both by ID and by coordinates) are added to the external eventStore
- This makes hydrated bookmark events available to useEventLoader and other hooks
- Fixes issue where /e/ path couldn't find events because they weren't in the global eventStore
- Now instant loading works for all bookmarked events
2025-10-22 00:42:25 +02:00
Gigi
2254586960 perf: check eventStore before setting loading state for instant cached event display
- Synchronously check eventStore first before setting loading state
- If event is cached, display it immediately without loading spinner
- Only set loading state if event not found in cache
- Provides instant display of events that are already hydrated
- Improves perceived performance when navigating to bookmarked events
2025-10-22 00:38:42 +02:00
Gigi
18c78c19be fix: render events as plain text html instead of markdown
- kind:1 notes are plain text, not markdown
- Changed from markdown to html rendering
- HTML-escape content to prevent injection
- Preserve whitespace and newlines for plain text display
- Display event metadata in styled HTML header
2025-10-22 00:36:55 +02:00
Gigi
167d5f2041 fix: clear reader content when loading event and set proper selectedUrl
- Clear readerContent at start of loading to ensure old content doesn't persist
- Set selectedUrl to nostr:eventId to match pattern used in other loaders
- This ensures consistent behavior across all content loaders
2025-10-22 00:35:33 +02:00
Gigi
cce7507e50 fix: properly extract eventId from route params
- Add eventId to useParams instead of manually parsing pathname
- useParams automatically extracts eventId from /e/:eventId route
- Add debug logging to track event loading
- This fixes the issue where eventId wasn't being passed to useEventLoader
2025-10-22 00:30:54 +02:00
Gigi
e83d4dbcdb feat: render notes like articles with markdown processing
- Change useEventLoader to set markdown instead of html
- Notes now get proper markdown processing and rendering similar to articles
- Use markdown comments for event metadata instead of HTML
- This enables proper styling and markdown features for note display
2025-10-22 00:28:29 +02:00
Gigi
a5bdde68fc fix: resolve all linter and type check errors
- Fix mergeMap concurrency syntax (pass as second parameter, not object)
- Fix type casting in CompactView debug logging
- Update useEventLoader to use ReadableContent type
- Fix eventStore type compatibility in useEventLoader
- All linter and TypeScript checks now pass
2025-10-22 00:27:45 +02:00
Gigi
5551cc3a55 feat: add relay.nostr.band as hardcoded relay
- Create HARDCODED_RELAYS constant with relay.nostr.band
- Always include hardcoded relays in relay pool
- Update computeRelaySet calls to use HARDCODED_RELAYS
- Ensures we can fetch events even if user has no relay list
- relay.nostr.band is a public searchable relay that indexes all events
2025-10-22 00:23:01 +02:00
Gigi
145ff138b0 feat: integrate event viewer into three-pane layout for /e/:eventId
- Create useEventLoader hook to fetch and display individual events
- Events display in middle pane with metadata (ID, timestamp, kind)
- Integrates with existing Bookmarks three-pane layout
- Remove standalone EventViewer component
- Route /e/:eventId now uses Bookmarks component
- Metadata displayed above event content for context
2025-10-22 00:22:04 +02:00
Gigi
5bd5686805 feat: add /e/:eventId route to display individual notes
- New EventViewer component to display kind:1 notes and other events
- Shows event ID, creation time, and content with RichContent rendering
- Add /e/:eventId route in App.tsx
- Update CompactView to navigate to /e/:eventId when clicking kind:1 bookmarks
- Mobile-optimized styling with back button and full viewport display
- Fallback for missing events with error message
2025-10-22 00:19:20 +02:00
Gigi
d2c1a16ca6 chore: remove verbose debug logging from hydration
- Clean up console output after diagnosing ID mismatch issue
- Keep error logging for when matches aren't found
- Deduplication before hydration now working
2025-10-22 00:17:03 +02:00
Gigi
b8242312b5 fix: deduplicate bookmarks before requesting hydration
- Collect all items, then dedupe before separating IDs/coordinates
- Prevents requesting hydration for 410 duplicate items
- Only requests ~96 unique event IDs instead
- Events are still hydrated for both public and private lists
- Dedupe after combining hydrated results
2025-10-22 00:15:27 +02:00
Gigi
96ef227f79 debug: log all fetched events to identify ID mismatch
- Show sample of note IDs being requested
- Log every event fetched with kind and content length
- Helps diagnose why kind:1 events aren't in the hydration map
2025-10-22 00:13:38 +02:00
Gigi
30ed5fb436 fix: batch event hydration with concurrency limit
- Replace merge(...map(eventLoader)) with mergeMap concurrency: 5
- Prevents overwhelming relays with 96+ simultaneous requests
- EventLoader now properly throttles to 5 concurrent requests at a time
- Fixes issue where only ~7 out of 96 events were being fetched
2025-10-22 00:12:34 +02:00
Gigi
42d7143845 debug: add logging for event ID requests
- Log how many note IDs and coordinates we're requesting
- Log how many unique event IDs are passed to EventLoader
- Track if all bookmarks are being requested for hydration
2025-10-22 00:11:06 +02:00
Gigi
f02bc21faf debug: simplify hydration logging for easier diagnosis
- Show how many items were matched in the map
- If zero matches, show actual IDs from both sides
- Makes it easy to see ID mismatch issues
2025-10-22 00:10:13 +02:00
Gigi
0f5d42465d debug: add detailed logging to hydrateItems
- Log which kind:1 items are being processed
- Show how many match events in the idToEvent map
- Compare sample IDs from items vs map keys
- Identify ID mismatch issue between bookmarks and fetched events
2025-10-22 00:08:47 +02:00
Gigi
004367bab6 debug: log the actual Bookmark object being emitted to component
- Show what's actually in individualBookmarks when emitted
- Check if content is present in the emitted object vs what component receives
- Identify if the issue is in hydration or state propagation
2025-10-22 00:05:04 +02:00
Gigi
312adea9f9 debug: add hydration logging to diagnose empty bookmarks
- Log when kind:1 events are fetched from relays
- Log when bookmarks are emitted with hydration status
- Track how many events are in the idToEvent map
- Check if event IDs match between bookmarks and fetched events
2025-10-22 00:03:14 +02:00
Gigi
a081b26333 feat: show event IDs for empty bookmarks and add debug logging
- Display event ID (first 12 chars) when bookmark content is missing
- Shows ID in dimmed code font as fallback for empty items
- Add debug console logging to identify which bookmarks are empty
- Helps diagnose hydration issues and identify events that aren't loading
2025-10-22 00:02:11 +02:00
Gigi
51e48804fe debug: remove console logging for kind:1 hydration
- Removed 📝, 💧, 🎨 and 📊 debug logs
- These were added for troubleshooting but are no longer needed
- Kind:1 content hydration and rendering is working correctly
2025-10-21 23:58:16 +02:00
Gigi
e08ce0e477 debug: add BookmarkList logging to track kind:1 filtering
- Log how many kind:1 bookmarks make it past the hasContent filter
- Show sample content to verify hydration is reaching the list
- Help identify where bookmarks are being filtered out
2025-10-21 23:55:10 +02:00
Gigi
2791c69ebe debug: add logging to CompactView to diagnose missing content rendering
- Log when kind:1 without URLs is being rendered
- Check if bookmark.content is actually present at render time
- Help diagnose why text isn't displaying even though it's hydrated
2025-10-21 23:54:15 +02:00
Gigi
96451e6173 debug: add logging to track kind:1 event hydration
- Log when kind:1 events are fetched by EventLoader
- Log when kind:1 events are hydrated with content
- Helps diagnose why text content isn't displaying for bookmarked notes
2025-10-21 23:52:39 +02:00
Gigi
d20cc684c3 feat: ensure kind:1 events display their text content in bookmarks bar
- Update hydrateItems to parse content for all events with text
- Previously, kind:1 events without URLs would appear empty in the bookmarks list
- Now any kind:1 event will display its text content appropriately
- Improves handling of short-form text notes in bookmarks
2025-10-21 23:50:12 +02:00
Gigi
4316c46a4d docs: update CHANGELOG for v0.10.7 2025-10-21 23:40:05 +02:00
11 changed files with 410 additions and 35 deletions

View File

@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
## [0.10.7] - 2025-10-21
### Fixed
- Profile pages now display all writings correctly
- Events are now stored in eventStore as they stream in from relays
- `fetchBlogPostsFromAuthors` now accepts `eventStore` parameter like other fetch functions
- Ensures all writings appear on `/p/` routes, not just the first few
- Background fetching of highlights and writings uses consistent patterns
### Changed
- Simplified profile background fetching logic for better maintainability
- Extracted relay URLs to variable for clarity
- Consistent error handling patterns across fetch functions
- Clearer comments about no-limit fetching behavior
## [0.10.6] - 2025-10-21
### Added

View File

@@ -21,7 +21,7 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
import { RELAYS } from './config/relays'
import { SkeletonThemeProvider } from './components/Skeletons'
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS } from './services/relayManager'
import { applyRelaySetToPool, getActiveRelayUrls, ALWAYS_LOCAL_RELAYS, HARDCODED_RELAYS } from './services/relayManager'
import { Bookmark } from './types/bookmarks'
import { bookmarkController } from './services/bookmarkController'
import { contactsController } from './services/contactsController'
@@ -95,7 +95,7 @@ function AppRoutes({
// Load bookmarks
if (bookmarks.length === 0 && !bookmarksLoading) {
bookmarkController.start({ relayPool, activeAccount, accountManager })
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
}
// Load contacts
@@ -348,6 +348,18 @@ function AppRoutes({
/>
}
/>
<Route
path="/e/:eventId"
element={
<Bookmarks
relayPool={relayPool}
onLogout={handleLogout}
bookmarks={bookmarks}
bookmarksLoading={bookmarksLoading}
onRefreshBookmarks={handleRefreshBookmarks}
/>
}
/>
<Route
path="/debug"
element={
@@ -615,7 +627,7 @@ function App() {
loadUserRelayList(pool, pubkey, {
onUpdate: (userRelays) => {
const interimRelays = computeRelaySet({
hardcoded: [],
hardcoded: HARDCODED_RELAYS,
bunker: bunkerRelays,
userList: userRelays,
blocked: [],
@@ -629,7 +641,7 @@ function App() {
const blockedRelays = await blockedPromise.catch(() => [])
const finalRelays = computeRelaySet({
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
bunker: bunkerRelays,
userList: userRelayList,
blocked: blockedRelays,

View File

@@ -1,4 +1,5 @@
import React from 'react'
import { useNavigate } from 'react-router-dom'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
import { IndividualBookmark } from '../../types/bookmarks'
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
contentTypeIcon,
readingProgress
}) => {
const navigate = useNavigate()
const isArticle = bookmark.kind === 30023
const isWebBookmark = bookmark.kind === 39701
const isClickable = hasUrls || isArticle || isWebBookmark
const isNote = bookmark.kind === 1
const isClickable = hasUrls || isArticle || isWebBookmark || isNote
// Calculate progress color (matching BlogPostCard logic)
const displayText = isArticle && articleSummary ? articleSummary : bookmark.content
// Calculate progress color
let progressColor = '#6366f1' // Default blue (reading)
if (readingProgress && readingProgress >= 0.95) {
progressColor = '#10b981' // Green (completed)
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
}
const handleCompactClick = () => {
if (!onSelectUrl) return
if (isArticle) {
onSelectUrl('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
onSelectUrl?.('', { id: bookmark.id, kind: bookmark.kind, tags: bookmark.tags, pubkey: bookmark.pubkey })
} else if (hasUrls) {
onSelectUrl(extractedUrls[0])
onSelectUrl?.(extractedUrls[0])
} else if (isNote) {
navigate(`/e/${bookmark.id}`)
}
}
// For articles, prefer summary; for others, use content
const displayText = isArticle && articleSummary
? articleSummary
: bookmark.content
return (
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
<div
@@ -64,10 +64,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
<span className="bookmark-type-compact">
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
</span>
{displayText && (
{displayText ? (
<div className="compact-text">
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
</div>
) : (
<div className="compact-text" style={{ opacity: 0.5, fontSize: '0.85em' }}>
<code>{bookmark.id.slice(0, 12)}...</code>
</div>
)}
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
{/* CTA removed */}

View File

@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
import { useBookmarksUI } from '../hooks/useBookmarksUI'
import { useRelayStatus } from '../hooks/useRelayStatus'
import { useOfflineSync } from '../hooks/useOfflineSync'
import { useEventLoader } from '../hooks/useEventLoader'
import { Bookmark } from '../types/bookmarks'
import ThreePaneLayout from './ThreePaneLayout'
import Explore from './Explore'
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
bookmarksLoading,
onRefreshBookmarks
}) => {
const { naddr, npub } = useParams<{ naddr?: string; npub?: string }>()
const { naddr, npub, eventId: eventIdParam } = useParams<{ naddr?: string; npub?: string; eventId?: string }>()
const location = useLocation()
const navigate = useNavigate()
const previousLocationRef = useRef<string>()
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
const showMe = location.pathname.startsWith('/me')
const showProfile = location.pathname.startsWith('/p/')
const showSupport = location.pathname === '/support'
const eventId = eventIdParam
// Extract tab from explore routes
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
@@ -255,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
setCurrentArticleEventId
})
// Load event if /e/:eventId route is used
useEventLoader({
eventId,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
})
// Classify highlights with levels based on user context
const classifiedHighlights = useMemo(() => {
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)

View File

@@ -485,7 +485,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
}
const handleOpenSearch = () => {
if (articleLinks) {
// For regular notes (kind:1), open via /e/ path
if (currentArticle?.kind === 1) {
const borisUrl = `${window.location.origin}/e/${currentArticle.id}`
window.open(borisUrl, '_blank', 'noopener,noreferrer')
} else if (articleLinks) {
// For articles, use search portal
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
}
setShowArticleMenu(false)

View File

@@ -0,0 +1 @@

132
src/hooks/useEventLoader.ts Normal file
View File

@@ -0,0 +1,132 @@
import { useEffect, useCallback } from 'react'
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { NostrEvent } from 'nostr-tools'
import { ReadableContent } from '../services/readerService'
import { eventManager } from '../services/eventManager'
import { fetchProfiles } from '../services/profileService'
interface UseEventLoaderProps {
eventId?: string
relayPool?: RelayPool | null
eventStore?: IEventStore | null
setSelectedUrl: (url: string) => void
setReaderContent: (content: ReadableContent | undefined) => void
setReaderLoading: (loading: boolean) => void
setIsCollapsed: (collapsed: boolean) => void
}
export function useEventLoader({
eventId,
relayPool,
eventStore,
setSelectedUrl,
setReaderContent,
setReaderLoading,
setIsCollapsed
}: UseEventLoaderProps) {
const displayEvent = useCallback((event: NostrEvent) => {
// Escape HTML in content and convert newlines to breaks for plain text display
const escapedContent = event.content
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\n/g, '<br />')
// Initial title
let title = `Note (${event.kind})`
if (event.kind === 1) {
title = `Note by @${event.pubkey.slice(0, 8)}...`
}
// Emit immediately
const baseContent: ReadableContent = {
url: '',
html: `<div style="white-space: pre-wrap; word-break: break-word;">${escapedContent}</div>`,
title,
published: event.created_at
}
setReaderContent(baseContent)
// Background: resolve author profile for kind:1 and update title
if (event.kind === 1 && eventStore) {
(async () => {
try {
let resolved = ''
// First, try to get from event store cache
const storedProfile = eventStore.getEvent(event.pubkey + ':0')
if (storedProfile) {
try {
const obj = JSON.parse(storedProfile.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
}
}
// If not found in event store, fetch from relays
if (!resolved && relayPool) {
const profiles = await fetchProfiles(relayPool, eventStore as unknown as IEventStore, [event.pubkey])
if (profiles && profiles.length > 0) {
const latest = profiles.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))[0]
try {
const obj = JSON.parse(latest.content || '{}') as { name?: string; display_name?: string; nip05?: string }
resolved = obj.display_name || obj.name || obj.nip05 || ''
} catch {
// ignore parse errors
}
}
}
if (resolved) {
setReaderContent({ ...baseContent, title: `Note by @${resolved}` })
}
} catch {
// ignore profile failures; keep fallback title
}
})()
}
}, [setReaderContent, relayPool, eventStore])
// Initialize event manager with services
useEffect(() => {
eventManager.setServices(eventStore || null, relayPool || null)
}, [eventStore, relayPool])
useEffect(() => {
if (!eventId) return
setReaderLoading(true)
setReaderContent(undefined)
setSelectedUrl(`nostr-event:${eventId}`) // sentinel: truthy selection, not treated as article
setIsCollapsed(false)
// Fetch using event manager (handles cache, deduplication, and retry)
let cancelled = false
eventManager.fetchEvent(eventId).then(
(event) => {
if (!cancelled) {
displayEvent(event)
setReaderLoading(false)
}
},
(err) => {
if (!cancelled) {
const errorContent: ReadableContent = {
url: '',
html: `<div style="padding: 1rem; color: var(--color-error, red);">Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}</div>`,
title: 'Error'
}
setReaderContent(errorContent)
setReaderLoading(false)
}
}
)
return () => {
cancelled = true
}
}, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
}

View File

@@ -3,7 +3,8 @@ import { Helpers, EventStore } from 'applesauce-core'
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
import { EventPointer } from 'nostr-tools/nip19'
import { merge } from 'rxjs'
import { from } from 'rxjs'
import { mergeMap } from 'rxjs/operators'
import { queryEvents } from './dataFetch'
import { KINDS } from '../config/kinds'
import { RELAYS } from '../config/relays'
@@ -69,6 +70,7 @@ class BookmarkController {
private eventStore = new EventStore()
private eventLoader: ReturnType<typeof createEventLoader> | null = null
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
private externalEventStore: EventStore | null = null
onRawEvent(cb: RawEventCallback): () => void {
this.rawEventListeners.push(cb)
@@ -138,8 +140,11 @@ class BookmarkController {
// Convert IDs to EventPointers
const pointers: EventPointer[] = unique.map(id => ({ id }))
// Use EventLoader - it auto-batches and streams results
merge(...pointers.map(this.eventLoader)).subscribe({
// Use mergeMap with concurrency limit instead of merge to properly batch requests
// This prevents overwhelming relays with 96+ simultaneous requests
from(pointers).pipe(
mergeMap(pointer => this.eventLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
@@ -153,6 +158,11 @@ class BookmarkController {
idToEvent.set(coordinate, event)
}
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
@@ -183,8 +193,10 @@ class BookmarkController {
identifier: c.identifier
}))
// Use AddressLoader - it auto-batches and streams results
merge(...pointers.map(this.addressLoader)).subscribe({
// Use mergeMap with concurrency limit instead of merge to properly batch requests
from(pointers).pipe(
mergeMap(pointer => this.addressLoader!(pointer), 5)
).subscribe({
next: (event) => {
// Check if hydration was cancelled
if (this.hydrationGeneration !== generation) return
@@ -194,6 +206,11 @@ class BookmarkController {
idToEvent.set(coordinate, event)
idToEvent.set(event.id, event)
// Add to external event store if available
if (this.externalEventStore) {
this.externalEventStore.add(event)
}
onProgress()
},
error: () => {
@@ -244,30 +261,42 @@ class BookmarkController {
})
const allItems = [...publicItemsAll, ...privateItemsAll]
const deduped = dedupeBookmarksById(allItems)
// Separate hex IDs from coordinates
// Separate hex IDs from coordinates for fetching
const noteIds: string[] = []
const coordinates: string[] = []
allItems.forEach(i => {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
// Request hydration for all items that don't have content yet
deduped.forEach(i => {
// If item has no content, we need to fetch it
if (!i.content || i.content.length === 0) {
if (/^[0-9a-f]{64}$/i.test(i.id)) {
noteIds.push(i.id)
} else if (i.id.includes(':')) {
coordinates.push(i.id)
}
}
})
console.log(`📋 Requesting hydration for: ${noteIds.length} note IDs, ${coordinates.length} coordinates`)
// Helper to build and emit bookmarks
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
const allBookmarks = dedupeBookmarksById([
// Now hydrate the ORIGINAL items (which may have duplicates), using the deduplicated results
// This preserves the original public/private split while still getting all the content
const allBookmarks = [
...hydrateItems(publicItemsAll, idToEvent),
...hydrateItems(privateItemsAll, idToEvent)
])
]
const enriched = allBookmarks.map(b => ({
...b,
tags: b.tags || [],
content: b.content || ''
// Prefer hydrated content; fallback to any cached event content in external store
content: b.content && b.content.length > 0
? b.content
: (this.externalEventStore?.getEvent(b.id)?.content || '')
}))
const sortedBookmarks = enriched
@@ -324,8 +353,12 @@ class BookmarkController {
relayPool: RelayPool
activeAccount: unknown
accountManager: { getActive: () => unknown }
eventStore?: EventStore
}): Promise<void> {
const { relayPool, activeAccount, accountManager } = options
const { relayPool, activeAccount, accountManager, eventStore } = options
// Store the external event store reference for adding hydrated events
this.externalEventStore = eventStore || null
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
return

View File

@@ -184,6 +184,9 @@ export function hydrateItems(
}
}
// Ensure all events with content get parsed content for proper rendering
const parsedContent = content ? (getParsedContent(content) as ParsedContent) : undefined
return {
...item,
pubkey: ev.pubkey || item.pubkey,
@@ -191,7 +194,7 @@ export function hydrateItems(
created_at: ev.created_at || item.created_at,
kind: ev.kind || item.kind,
tags: ev.tags || item.tags,
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
parsedContent: parsedContent || item.parsedContent
}
})
.filter(item => {

View File

@@ -0,0 +1,148 @@
import { RelayPool } from 'applesauce-relay'
import { IEventStore } from 'applesauce-core'
import { createEventLoader } from 'applesauce-loaders/loaders'
import { NostrEvent } from 'nostr-tools'
type PendingRequest = {
resolve: (event: NostrEvent) => void
reject: (error: Error) => void
}
/**
* Centralized event manager for event fetching and caching
* Handles deduplication of concurrent requests and coordinate with relay pool
*/
class EventManager {
private eventStore: IEventStore | null = null
private relayPool: RelayPool | null = null
private eventLoader: ReturnType<typeof createEventLoader> | null = null
// Track pending requests to deduplicate and resolve all at once
private pendingRequests = new Map<string, PendingRequest[]>()
// Safety timeout for event fetches (ms)
private fetchTimeoutMs = 12000
/**
* Initialize the event manager with event store and relay pool
*/
setServices(eventStore: IEventStore | null, relayPool: RelayPool | null): void {
this.eventStore = eventStore
this.relayPool = relayPool
// Recreate loader when services change
if (relayPool) {
this.eventLoader = createEventLoader(relayPool, {
eventStore: eventStore || undefined
})
// Retry any pending requests now that we have a loader
this.retryAllPending()
}
}
/**
* Get cached event from event store
*/
getCachedEvent(eventId: string): NostrEvent | null {
if (!this.eventStore) return null
return this.eventStore.getEvent(eventId) || null
}
/**
* Fetch an event by ID, returning a promise
* Automatically deduplicates concurrent requests for the same event
*/
fetchEvent(eventId: string): Promise<NostrEvent> {
// Check cache first
const cached = this.getCachedEvent(eventId)
if (cached) {
return Promise.resolve(cached)
}
return new Promise<NostrEvent>((resolve, reject) => {
// Check if we're already fetching this event
if (this.pendingRequests.has(eventId)) {
// Add to existing request queue
this.pendingRequests.get(eventId)!.push({ resolve, reject })
return
}
// Start a new fetch request
this.pendingRequests.set(eventId, [{ resolve, reject }])
this.fetchFromRelay(eventId)
})
}
private resolvePending(eventId: string, event: NostrEvent): void {
const requests = this.pendingRequests.get(eventId) || []
this.pendingRequests.delete(eventId)
requests.forEach(req => req.resolve(event))
}
private rejectPending(eventId: string, error: Error): void {
const requests = this.pendingRequests.get(eventId) || []
this.pendingRequests.delete(eventId)
requests.forEach(req => req.reject(error))
}
/**
* Actually fetch the event from relay
*/
private fetchFromRelay(eventId: string): void {
// If no loader yet, schedule retry
if (!this.relayPool || !this.eventLoader) {
setTimeout(() => {
if (this.eventLoader && this.pendingRequests.has(eventId)) {
this.fetchFromRelay(eventId)
}
}, 500)
return
}
let delivered = false
const subscription = this.eventLoader({ id: eventId }).subscribe({
next: (event: NostrEvent) => {
delivered = true
clearTimeout(timeoutId)
this.resolvePending(eventId, event)
subscription.unsubscribe()
},
error: (err: unknown) => {
clearTimeout(timeoutId)
const error = err instanceof Error ? err : new Error(String(err))
this.rejectPending(eventId, error)
subscription.unsubscribe()
},
complete: () => {
// Completed without next - consider not found
if (!delivered) {
clearTimeout(timeoutId)
this.rejectPending(eventId, new Error('Event not found'))
}
subscription.unsubscribe()
}
})
// Safety timeout
const timeoutId = setTimeout(() => {
if (!delivered) {
this.rejectPending(eventId, new Error('Timed out fetching event'))
subscription.unsubscribe()
}
}, this.fetchTimeoutMs)
}
/**
* Retry all pending requests after relay pool becomes available
*/
private retryAllPending(): void {
const pendingIds = Array.from(this.pendingRequests.keys())
pendingIds.forEach(eventId => {
this.fetchFromRelay(eventId)
})
}
}
// Singleton instance
export const eventManager = new EventManager()

View File

@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
'ws://localhost:4869'
]
/**
* Hardcoded relays that are always included
*/
export const HARDCODED_RELAYS = [
'wss://relay.nostr.band'
]
/**
* Gets active relay URLs from the relay pool
*/