= ({
- {displayText && (
+ {displayText ? (
60 ? '…' : '')} className="" />
+ ) : (
+
+ {bookmark.id.slice(0, 12)}...
+
)}
{formatDateCompact(bookmark.created_at)}
{/* CTA removed */}
diff --git a/src/components/Bookmarks.tsx b/src/components/Bookmarks.tsx
index 1c2efa13..2c2a9281 100644
--- a/src/components/Bookmarks.tsx
+++ b/src/components/Bookmarks.tsx
@@ -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
= ({
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()
@@ -55,6 +56,7 @@ const Bookmarks: React.FC = ({
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 = ({
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)
diff --git a/src/components/ContentPanel.tsx b/src/components/ContentPanel.tsx
index 26e33c7b..e77a6917 100644
--- a/src/components/ContentPanel.tsx
+++ b/src/components/ContentPanel.tsx
@@ -485,7 +485,12 @@ const ContentPanel: React.FC = ({
}
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)
diff --git a/src/components/EventViewer.tsx b/src/components/EventViewer.tsx
new file mode 100644
index 00000000..8b137891
--- /dev/null
+++ b/src/components/EventViewer.tsx
@@ -0,0 +1 @@
+
diff --git a/src/hooks/useEventLoader.ts b/src/hooks/useEventLoader.ts
new file mode 100644
index 00000000..c2cea86e
--- /dev/null
+++ b/src/hooks/useEventLoader.ts
@@ -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, '&')
+ .replace(//g, '>')
+ .replace(/\n/g, '
')
+
+ // 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: `${escapedContent}
`,
+ 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: `Failed to load event: ${err instanceof Error ? err.message : 'Unknown error'}
`,
+ title: 'Error'
+ }
+ setReaderContent(errorContent)
+ setReaderLoading(false)
+ }
+ }
+ )
+
+ return () => {
+ cancelled = true
+ }
+ }, [eventId, displayEvent, setReaderLoading, setSelectedUrl, setIsCollapsed, setReaderContent])
+}
diff --git a/src/services/bookmarkController.ts b/src/services/bookmarkController.ts
index 3a5c16c5..aa6e6d08 100644
--- a/src/services/bookmarkController.ts
+++ b/src/services/bookmarkController.ts
@@ -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 | null = null
private addressLoader: ReturnType | 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) => {
- 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 {
- 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
diff --git a/src/services/bookmarkHelpers.ts b/src/services/bookmarkHelpers.ts
index d8803a42..3795d5a7 100644
--- a/src/services/bookmarkHelpers.ts
+++ b/src/services/bookmarkHelpers.ts
@@ -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 => {
diff --git a/src/services/eventManager.ts b/src/services/eventManager.ts
new file mode 100644
index 00000000..92e0d33d
--- /dev/null
+++ b/src/services/eventManager.ts
@@ -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 | null = null
+
+ // Track pending requests to deduplicate and resolve all at once
+ private pendingRequests = new Map()
+
+ // 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 {
+ // Check cache first
+ const cached = this.getCachedEvent(eventId)
+ if (cached) {
+ return Promise.resolve(cached)
+ }
+
+ return new Promise((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()
diff --git a/src/services/relayManager.ts b/src/services/relayManager.ts
index 48d89d23..877cbdbe 100644
--- a/src/services/relayManager.ts
+++ b/src/services/relayManager.ts
@@ -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
*/