diff --git a/src/App.tsx b/src/App.tsx index fe26e955..992708f9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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({ /> } /> + + } + /> { 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, diff --git a/src/components/BookmarkViews/CompactView.tsx b/src/components/BookmarkViews/CompactView.tsx index 6be3d76f..30edc84a 100644 --- a/src/components/BookmarkViews/CompactView.tsx +++ b/src/components/BookmarkViews/CompactView.tsx @@ -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 = ({ 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 = ({ } 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 (
= ({ - {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 */