mirror of
https://github.com/dergigi/boris.git
synced 2026-02-17 04:54:56 +01:00
Compare commits
44 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
366e10b23a | ||
|
|
bb66823915 | ||
|
|
f09973c858 | ||
|
|
d03726801d | ||
|
|
164e941a1f | ||
|
|
6def58f128 | ||
|
|
347e23ff6f | ||
|
|
934768ebf2 | ||
|
|
60e9ede9cf | ||
|
|
c70e6bc2aa | ||
|
|
ab8665815b | ||
|
|
1929b50892 | ||
|
|
160dca628d | ||
|
|
c04ba0c787 | ||
|
|
479d9314bd | ||
|
|
b9d5e501f4 | ||
|
|
43e0dd76c4 | ||
|
|
dc9a49e895 | ||
|
|
3200bdf378 | ||
|
|
2254586960 | ||
|
|
18c78c19be | ||
|
|
167d5f2041 | ||
|
|
cce7507e50 | ||
|
|
e83d4dbcdb | ||
|
|
a5bdde68fc | ||
|
|
5551cc3a55 | ||
|
|
145ff138b0 | ||
|
|
5bd5686805 | ||
|
|
d2c1a16ca6 | ||
|
|
b8242312b5 | ||
|
|
96ef227f79 | ||
|
|
30ed5fb436 | ||
|
|
42d7143845 | ||
|
|
f02bc21faf | ||
|
|
0f5d42465d | ||
|
|
004367bab6 | ||
|
|
312adea9f9 | ||
|
|
a081b26333 | ||
|
|
51e48804fe | ||
|
|
e08ce0e477 | ||
|
|
2791c69ebe | ||
|
|
96451e6173 | ||
|
|
d20cc684c3 | ||
|
|
4316c46a4d |
17
CHANGELOG.md
17
CHANGELOG.md
@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.10.6] - 2025-10-21
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
20
src/App.tsx
20
src/App.tsx
@@ -21,7 +21,7 @@ import { useOnlineStatus } from './hooks/useOnlineStatus'
|
|||||||
import { RELAYS } from './config/relays'
|
import { RELAYS } from './config/relays'
|
||||||
import { SkeletonThemeProvider } from './components/Skeletons'
|
import { SkeletonThemeProvider } from './components/Skeletons'
|
||||||
import { loadUserRelayList, loadBlockedRelays, computeRelaySet } from './services/relayListService'
|
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 { Bookmark } from './types/bookmarks'
|
||||||
import { bookmarkController } from './services/bookmarkController'
|
import { bookmarkController } from './services/bookmarkController'
|
||||||
import { contactsController } from './services/contactsController'
|
import { contactsController } from './services/contactsController'
|
||||||
@@ -95,7 +95,7 @@ function AppRoutes({
|
|||||||
|
|
||||||
// Load bookmarks
|
// Load bookmarks
|
||||||
if (bookmarks.length === 0 && !bookmarksLoading) {
|
if (bookmarks.length === 0 && !bookmarksLoading) {
|
||||||
bookmarkController.start({ relayPool, activeAccount, accountManager })
|
bookmarkController.start({ relayPool, activeAccount, accountManager, eventStore: eventStore || undefined })
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load contacts
|
// 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
|
<Route
|
||||||
path="/debug"
|
path="/debug"
|
||||||
element={
|
element={
|
||||||
@@ -615,7 +627,7 @@ function App() {
|
|||||||
loadUserRelayList(pool, pubkey, {
|
loadUserRelayList(pool, pubkey, {
|
||||||
onUpdate: (userRelays) => {
|
onUpdate: (userRelays) => {
|
||||||
const interimRelays = computeRelaySet({
|
const interimRelays = computeRelaySet({
|
||||||
hardcoded: [],
|
hardcoded: HARDCODED_RELAYS,
|
||||||
bunker: bunkerRelays,
|
bunker: bunkerRelays,
|
||||||
userList: userRelays,
|
userList: userRelays,
|
||||||
blocked: [],
|
blocked: [],
|
||||||
@@ -629,7 +641,7 @@ function App() {
|
|||||||
const blockedRelays = await blockedPromise.catch(() => [])
|
const blockedRelays = await blockedPromise.catch(() => [])
|
||||||
|
|
||||||
const finalRelays = computeRelaySet({
|
const finalRelays = computeRelaySet({
|
||||||
hardcoded: userRelayList.length > 0 ? [] : RELAYS,
|
hardcoded: userRelayList.length > 0 ? HARDCODED_RELAYS : RELAYS,
|
||||||
bunker: bunkerRelays,
|
bunker: bunkerRelays,
|
||||||
userList: userRelayList,
|
userList: userRelayList,
|
||||||
blocked: blockedRelays,
|
blocked: blockedRelays,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react'
|
import React from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
|
||||||
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
import { IconDefinition } from '@fortawesome/fontawesome-svg-core'
|
||||||
import { IndividualBookmark } from '../../types/bookmarks'
|
import { IndividualBookmark } from '../../types/bookmarks'
|
||||||
@@ -26,11 +27,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
contentTypeIcon,
|
contentTypeIcon,
|
||||||
readingProgress
|
readingProgress
|
||||||
}) => {
|
}) => {
|
||||||
|
const navigate = useNavigate()
|
||||||
const isArticle = bookmark.kind === 30023
|
const isArticle = bookmark.kind === 30023
|
||||||
const isWebBookmark = bookmark.kind === 39701
|
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)
|
let progressColor = '#6366f1' // Default blue (reading)
|
||||||
if (readingProgress && readingProgress >= 0.95) {
|
if (readingProgress && readingProgress >= 0.95) {
|
||||||
progressColor = '#10b981' // Green (completed)
|
progressColor = '#10b981' // Green (completed)
|
||||||
@@ -39,20 +44,15 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleCompactClick = () => {
|
const handleCompactClick = () => {
|
||||||
if (!onSelectUrl) return
|
|
||||||
|
|
||||||
if (isArticle) {
|
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) {
|
} 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 (
|
return (
|
||||||
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
<div key={`${bookmark.id}-${index}`} className={`individual-bookmark compact ${bookmark.isPrivate ? 'private-bookmark' : ''}`}>
|
||||||
<div
|
<div
|
||||||
@@ -64,10 +64,14 @@ export const CompactView: React.FC<CompactViewProps> = ({
|
|||||||
<span className="bookmark-type-compact">
|
<span className="bookmark-type-compact">
|
||||||
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
<FontAwesomeIcon icon={contentTypeIcon} className="content-type-icon" />
|
||||||
</span>
|
</span>
|
||||||
{displayText && (
|
{displayText ? (
|
||||||
<div className="compact-text">
|
<div className="compact-text">
|
||||||
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
<RichContent content={displayText.slice(0, 60) + (displayText.length > 60 ? '…' : '')} className="" />
|
||||||
</div>
|
</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>
|
<span className="bookmark-date-compact">{formatDateCompact(bookmark.created_at)}</span>
|
||||||
{/* CTA removed */}
|
{/* CTA removed */}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useHighlightCreation } from '../hooks/useHighlightCreation'
|
|||||||
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
import { useBookmarksUI } from '../hooks/useBookmarksUI'
|
||||||
import { useRelayStatus } from '../hooks/useRelayStatus'
|
import { useRelayStatus } from '../hooks/useRelayStatus'
|
||||||
import { useOfflineSync } from '../hooks/useOfflineSync'
|
import { useOfflineSync } from '../hooks/useOfflineSync'
|
||||||
|
import { useEventLoader } from '../hooks/useEventLoader'
|
||||||
import { Bookmark } from '../types/bookmarks'
|
import { Bookmark } from '../types/bookmarks'
|
||||||
import ThreePaneLayout from './ThreePaneLayout'
|
import ThreePaneLayout from './ThreePaneLayout'
|
||||||
import Explore from './Explore'
|
import Explore from './Explore'
|
||||||
@@ -38,7 +39,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
bookmarksLoading,
|
bookmarksLoading,
|
||||||
onRefreshBookmarks
|
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 location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const previousLocationRef = useRef<string>()
|
const previousLocationRef = useRef<string>()
|
||||||
@@ -55,6 +56,7 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
const showMe = location.pathname.startsWith('/me')
|
const showMe = location.pathname.startsWith('/me')
|
||||||
const showProfile = location.pathname.startsWith('/p/')
|
const showProfile = location.pathname.startsWith('/p/')
|
||||||
const showSupport = location.pathname === '/support'
|
const showSupport = location.pathname === '/support'
|
||||||
|
const eventId = eventIdParam
|
||||||
|
|
||||||
// Extract tab from explore routes
|
// Extract tab from explore routes
|
||||||
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
const exploreTab = location.pathname === '/explore/writings' ? 'writings' : 'highlights'
|
||||||
@@ -255,6 +257,17 @@ const Bookmarks: React.FC<BookmarksProps> = ({
|
|||||||
setCurrentArticleEventId
|
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
|
// Classify highlights with levels based on user context
|
||||||
const classifiedHighlights = useMemo(() => {
|
const classifiedHighlights = useMemo(() => {
|
||||||
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
return classifyHighlights(highlights, activeAccount?.pubkey, followedPubkeys)
|
||||||
|
|||||||
@@ -485,7 +485,12 @@ const ContentPanel: React.FC<ContentPanelProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleOpenSearch = () => {
|
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')
|
window.open(getSearchUrl(articleLinks.naddr), '_blank', 'noopener,noreferrer')
|
||||||
}
|
}
|
||||||
setShowArticleMenu(false)
|
setShowArticleMenu(false)
|
||||||
|
|||||||
1
src/components/EventViewer.tsx
Normal file
1
src/components/EventViewer.tsx
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
132
src/hooks/useEventLoader.ts
Normal file
132
src/hooks/useEventLoader.ts
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.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])
|
||||||
|
}
|
||||||
@@ -3,7 +3,8 @@ import { Helpers, EventStore } from 'applesauce-core'
|
|||||||
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
import { createEventLoader, createAddressLoader } from 'applesauce-loaders/loaders'
|
||||||
import { NostrEvent } from 'nostr-tools'
|
import { NostrEvent } from 'nostr-tools'
|
||||||
import { EventPointer } from 'nostr-tools/nip19'
|
import { EventPointer } from 'nostr-tools/nip19'
|
||||||
import { merge } from 'rxjs'
|
import { from } from 'rxjs'
|
||||||
|
import { mergeMap } from 'rxjs/operators'
|
||||||
import { queryEvents } from './dataFetch'
|
import { queryEvents } from './dataFetch'
|
||||||
import { KINDS } from '../config/kinds'
|
import { KINDS } from '../config/kinds'
|
||||||
import { RELAYS } from '../config/relays'
|
import { RELAYS } from '../config/relays'
|
||||||
@@ -69,6 +70,7 @@ class BookmarkController {
|
|||||||
private eventStore = new EventStore()
|
private eventStore = new EventStore()
|
||||||
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
private eventLoader: ReturnType<typeof createEventLoader> | null = null
|
||||||
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
private addressLoader: ReturnType<typeof createAddressLoader> | null = null
|
||||||
|
private externalEventStore: EventStore | null = null
|
||||||
|
|
||||||
onRawEvent(cb: RawEventCallback): () => void {
|
onRawEvent(cb: RawEventCallback): () => void {
|
||||||
this.rawEventListeners.push(cb)
|
this.rawEventListeners.push(cb)
|
||||||
@@ -138,8 +140,11 @@ class BookmarkController {
|
|||||||
// Convert IDs to EventPointers
|
// Convert IDs to EventPointers
|
||||||
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
const pointers: EventPointer[] = unique.map(id => ({ id }))
|
||||||
|
|
||||||
// Use EventLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.eventLoader)).subscribe({
|
// This prevents overwhelming relays with 96+ simultaneous requests
|
||||||
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.eventLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -153,6 +158,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -183,8 +193,10 @@ class BookmarkController {
|
|||||||
identifier: c.identifier
|
identifier: c.identifier
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// Use AddressLoader - it auto-batches and streams results
|
// Use mergeMap with concurrency limit instead of merge to properly batch requests
|
||||||
merge(...pointers.map(this.addressLoader)).subscribe({
|
from(pointers).pipe(
|
||||||
|
mergeMap(pointer => this.addressLoader!(pointer), 5)
|
||||||
|
).subscribe({
|
||||||
next: (event) => {
|
next: (event) => {
|
||||||
// Check if hydration was cancelled
|
// Check if hydration was cancelled
|
||||||
if (this.hydrationGeneration !== generation) return
|
if (this.hydrationGeneration !== generation) return
|
||||||
@@ -194,6 +206,11 @@ class BookmarkController {
|
|||||||
idToEvent.set(coordinate, event)
|
idToEvent.set(coordinate, event)
|
||||||
idToEvent.set(event.id, event)
|
idToEvent.set(event.id, event)
|
||||||
|
|
||||||
|
// Add to external event store if available
|
||||||
|
if (this.externalEventStore) {
|
||||||
|
this.externalEventStore.add(event)
|
||||||
|
}
|
||||||
|
|
||||||
onProgress()
|
onProgress()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
@@ -244,30 +261,42 @@ class BookmarkController {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const allItems = [...publicItemsAll, ...privateItemsAll]
|
const allItems = [...publicItemsAll, ...privateItemsAll]
|
||||||
|
const deduped = dedupeBookmarksById(allItems)
|
||||||
|
|
||||||
// Separate hex IDs from coordinates
|
// Separate hex IDs from coordinates for fetching
|
||||||
const noteIds: string[] = []
|
const noteIds: string[] = []
|
||||||
const coordinates: string[] = []
|
const coordinates: string[] = []
|
||||||
|
|
||||||
allItems.forEach(i => {
|
// Request hydration for all items that don't have content yet
|
||||||
if (/^[0-9a-f]{64}$/i.test(i.id)) {
|
deduped.forEach(i => {
|
||||||
noteIds.push(i.id)
|
// If item has no content, we need to fetch it
|
||||||
} else if (i.id.includes(':')) {
|
if (!i.content || i.content.length === 0) {
|
||||||
coordinates.push(i.id)
|
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
|
// Helper to build and emit bookmarks
|
||||||
const emitBookmarks = (idToEvent: Map<string, NostrEvent>) => {
|
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(publicItemsAll, idToEvent),
|
||||||
...hydrateItems(privateItemsAll, idToEvent)
|
...hydrateItems(privateItemsAll, idToEvent)
|
||||||
])
|
]
|
||||||
|
|
||||||
const enriched = allBookmarks.map(b => ({
|
const enriched = allBookmarks.map(b => ({
|
||||||
...b,
|
...b,
|
||||||
tags: b.tags || [],
|
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
|
const sortedBookmarks = enriched
|
||||||
@@ -324,8 +353,12 @@ class BookmarkController {
|
|||||||
relayPool: RelayPool
|
relayPool: RelayPool
|
||||||
activeAccount: unknown
|
activeAccount: unknown
|
||||||
accountManager: { getActive: () => unknown }
|
accountManager: { getActive: () => unknown }
|
||||||
|
eventStore?: EventStore
|
||||||
}): Promise<void> {
|
}): 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') {
|
if (!activeAccount || typeof (activeAccount as { pubkey?: string }).pubkey !== 'string') {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
pubkey: ev.pubkey || item.pubkey,
|
pubkey: ev.pubkey || item.pubkey,
|
||||||
@@ -191,7 +194,7 @@ export function hydrateItems(
|
|||||||
created_at: ev.created_at || item.created_at,
|
created_at: ev.created_at || item.created_at,
|
||||||
kind: ev.kind || item.kind,
|
kind: ev.kind || item.kind,
|
||||||
tags: ev.tags || item.tags,
|
tags: ev.tags || item.tags,
|
||||||
parsedContent: ev.content ? (getParsedContent(content) as ParsedContent) : item.parsedContent
|
parsedContent: parsedContent || item.parsedContent
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.filter(item => {
|
.filter(item => {
|
||||||
|
|||||||
148
src/services/eventManager.ts
Normal file
148
src/services/eventManager.ts
Normal 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()
|
||||||
@@ -9,6 +9,13 @@ export const ALWAYS_LOCAL_RELAYS = [
|
|||||||
'ws://localhost:4869'
|
'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
|
* Gets active relay URLs from the relay pool
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user