Browse Source

bug-fixed publication pages

imwald
Silberengel 5 months ago
parent
commit
3ef2153c57
  1. 579
      src/components/Note/PublicationIndex/PublicationIndex.tsx
  2. 80
      src/services/client.service.ts

579
src/components/Note/PublicationIndex/PublicationIndex.tsx

@ -1,16 +1,18 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { Event } from 'nostr-tools' import { Event, nip19 } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useState, useCallback } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { normalizeUrl } from '@/lib/url'
import AsciidocArticle from '../AsciidocArticle/AsciidocArticle' import AsciidocArticle from '../AsciidocArticle/AsciidocArticle'
import MarkdownArticle from '../MarkdownArticle/MarkdownArticle' import MarkdownArticle from '../MarkdownArticle/MarkdownArticle'
import { generateBech32IdFromATag } from '@/lib/tag' import { generateBech32IdFromATag } from '@/lib/tag'
import client from '@/services/client.service' import client from '@/services/client.service'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { MoreVertical } from 'lucide-react' import { MoreVertical, RefreshCw } from 'lucide-react'
import indexedDb from '@/services/indexed-db.service' import indexedDb from '@/services/indexed-db.service'
import { isReplaceableEvent } from '@/lib/event' import { isReplaceableEvent } from '@/lib/event'
import { useSecondaryPage } from '@/PageManager'
interface PublicationReference { interface PublicationReference {
coordinate?: string coordinate?: string
@ -21,6 +23,7 @@ interface PublicationReference {
identifier?: string identifier?: string
relay?: string relay?: string
type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID type: 'a' | 'e' // 'a' for addressable (coordinate), 'e' for event ID
nestedRefs?: PublicationReference[] // Discovered nested references
} }
interface ToCItem { interface ToCItem {
@ -48,6 +51,7 @@ export default function PublicationIndex({
event: Event event: Event
className?: string className?: string
}) { }) {
const { push } = useSecondaryPage()
// Parse publication metadata from event tags // Parse publication metadata from event tags
const metadata = useMemo<PublicationMetadata>(() => { const metadata = useMemo<PublicationMetadata>(() => {
const meta: PublicationMetadata = { tags: [] } const meta: PublicationMetadata = { tags: [] }
@ -80,6 +84,10 @@ export default function PublicationIndex({
const [references, setReferences] = useState<PublicationReference[]>([]) const [references, setReferences] = useState<PublicationReference[]>([])
const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set()) const [visitedIndices, setVisitedIndices] = useState<Set<string>>(new Set())
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [retryCount, setRetryCount] = useState(0)
const [isRetrying, setIsRetrying] = useState(false)
const [failedReferences, setFailedReferences] = useState<PublicationReference[]>([])
const maxRetries = 5
// Build table of contents from references // Build table of contents from references
const tableOfContents = useMemo<ToCItem[]>(() => { const tableOfContents = useMemo<ToCItem[]>(() => {
@ -239,35 +247,17 @@ export default function PublicationIndex({
}) })
}, [currentCoordinate, event]) }, [currentCoordinate, event])
// Fetch referenced events // Fetch a single reference with retry logic
useEffect(() => { const fetchSingleReference = useCallback(async (
let isMounted = true ref: PublicationReference,
currentVisited: Set<string>,
const fetchReferences = async () => { isRetry = false
setIsLoading(true) ): Promise<PublicationReference | null> => {
const fetchedRefs: PublicationReference[] = []
// Capture current visitedIndices at the start of the fetch
const currentVisited = visitedIndices
// Add a timeout to prevent infinite loading on mobile
const timeout = setTimeout(() => {
if (isMounted) {
logger.warn('[PublicationIndex] Fetch timeout reached, setting loaded state')
setIsLoading(false)
}
}, 30000) // 30 second timeout
try {
for (const ref of referencesData) {
if (!isMounted) break
// Skip if this is a 30040 event we've already visited (prevent circular references) // Skip if this is a 30040 event we've already visited (prevent circular references)
if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) { if (ref.type === 'a' && ref.kind === ExtendedKind.PUBLICATION && ref.coordinate) {
if (currentVisited.has(ref.coordinate)) { if (currentVisited.has(ref.coordinate)) {
logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate) logger.debug('[PublicationIndex] Skipping visited 30040 index:', ref.coordinate)
fetchedRefs.push({ ...ref, event: undefined }) return { ...ref, event: undefined }
continue
} }
} }
@ -285,8 +275,156 @@ export default function PublicationIndex({
// If not found, try to fetch from relay // If not found, try to fetch from relay
if (!fetchedEvent) { if (!fetchedEvent) {
// For naddr, always use subscription-style query with comprehensive relay list (more reliable)
if (bech32Id.startsWith('naddr1')) {
try {
const { nip19 } = await import('nostr-tools')
const decoded = nip19.decode(bech32Id)
if (decoded.type === 'naddr') {
const filter: any = {
authors: [decoded.data.pubkey],
kinds: [decoded.data.kind],
limit: 1
}
if (decoded.data.identifier) {
filter['#d'] = [decoded.data.identifier]
}
// Use comprehensive relay list (same as initial fetch in client.service)
// Build relay list: FAST_READ_RELAY_URLS, user's favorite relays, user's relay list, decoded relays, BIG_RELAY_URLS
const { FAST_READ_RELAY_URLS, BIG_RELAY_URLS } = await import('@/constants')
const relayUrls = new Set<string>()
// Add FAST_READ_RELAY_URLS
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add user's favorite relays (kind 10012) and relay list (kind 10002) if logged in
try {
const userPubkey = (client as any).pubkey
if (userPubkey) {
// Fetch user's relay list (includes cache relays)
const userRelayList = await client.fetchRelayList(userPubkey)
if (userRelayList?.read) {
userRelayList.read.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Fetch user's favorite relays (kind 10012)
try {
const { ExtendedKind } = await import('@/constants')
const favoriteRelaysEvent = await (client as any).fetchReplaceableEvent?.(userPubkey, ExtendedKind.FAVORITE_RELAYS)
if (favoriteRelaysEvent) {
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]: [string, string]) => {
if (tagName === 'relay' && tagValue) {
const normalized = normalizeUrl(tagValue)
if (normalized) relayUrls.add(normalized)
}
})
}
} catch (error) {
// Ignore if favorite relays can't be fetched
}
}
} catch (error) {
// Ignore if user relay list can't be fetched
}
// Add relays from decoded naddr if available
if (decoded.data.relays && decoded.data.relays.length > 0) {
decoded.data.relays.forEach((url: string) => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
}
// Add BIG_RELAY_URLS as fallback
BIG_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
// Add SEARCHABLE_RELAY_URLS (important for finding events that search page finds)
const { SEARCHABLE_RELAY_URLS } = await import('@/constants')
SEARCHABLE_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relayUrls.add(normalized)
})
const finalRelayUrls = Array.from(relayUrls)
logger.debug('[PublicationIndex] Using', finalRelayUrls.length, 'relays for naddr query')
// Fetch using subscription-style query (more reliable for naddr)
// Use subscribeTimeline approach for better reliability (waits for eosed signals)
// This is the same approach NoteListPage uses, which successfully finds events
try {
let foundEvent: Event | undefined = undefined
let hasEosed = false
let subscriptionClosed = false
const { closer } = await client.subscribeTimeline(
[{ urls: finalRelayUrls, filter }],
{
onEvents: (events, eosed) => {
if (events.length > 0 && !foundEvent) {
foundEvent = events[0]
logger.debug('[PublicationIndex] Found event via naddr subscription:', ref.coordinate)
}
if (eosed) {
hasEosed = true
}
// Close subscription once we have an event and eosed
if ((foundEvent || hasEosed) && !subscriptionClosed) {
subscriptionClosed = true
closer()
}
},
onNew: () => {} // Not needed for one-time fetch
},
{ needSort: false }
)
// Wait for up to 10 seconds for events to arrive or eosed
const startTime = Date.now()
while (!foundEvent && !hasEosed && Date.now() - startTime < 10000) {
await new Promise(resolve => setTimeout(resolve, 100))
}
// Close subscription if still open
if (!subscriptionClosed) {
closer()
}
if (foundEvent) {
fetchedEvent = foundEvent
}
} catch (subError) {
logger.warn('[PublicationIndex] Subscription error, falling back to fetchEvents:', subError)
// Fallback to regular fetchEvents if subscription fails
const events = await client.fetchEvents(finalRelayUrls, [filter])
if (events.length > 0) {
fetchedEvent = events[0]
logger.debug('[PublicationIndex] Found event via naddr fetchEvents fallback:', ref.coordinate)
}
}
}
} catch (error) {
logger.warn('[PublicationIndex] Error trying naddr filter query:', error)
}
} else {
// For non-naddr (nevent/note), try fetchEvent first, then force retry
if (isRetry) {
fetchedEvent = await client.fetchEventForceRetry(bech32Id)
} else {
fetchedEvent = await client.fetchEvent(bech32Id) fetchedEvent = await client.fetchEvent(bech32Id)
// Save to cache as replaceable event }
}
// Save to cache as replaceable event if we fetched it
if (fetchedEvent) { if (fetchedEvent) {
await indexedDb.putReplaceableEvent(fetchedEvent) await indexedDb.putReplaceableEvent(fetchedEvent)
logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate) logger.debug('[PublicationIndex] Cached event with coordinate:', ref.coordinate)
@ -300,7 +438,12 @@ export default function PublicationIndex({
} else if (ref.type === 'e' && ref.eventId) { } else if (ref.type === 'e' && ref.eventId) {
// Handle event ID reference (e tag) // Handle event ID reference (e tag)
// Try to fetch by event ID first // Try to fetch by event ID first
if (isRetry) {
// On retry, use force retry to try more relays
fetchedEvent = await client.fetchEventForceRetry(ref.eventId)
} else {
fetchedEvent = await client.fetchEvent(ref.eventId) fetchedEvent = await client.fetchEvent(ref.eventId)
}
if (fetchedEvent) { if (fetchedEvent) {
// Check if this is a replaceable event kind // Check if this is a replaceable event kind
@ -310,7 +453,6 @@ export default function PublicationIndex({
logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId) logger.debug('[PublicationIndex] Cached replaceable event with ID:', ref.eventId)
} else { } else {
// For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents // For non-replaceable events, we'll link them to master later via putPublicationWithNestedEvents
// Just cache them for now without master link - they'll be properly linked when we call putPublicationWithNestedEvents
logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId) logger.debug('[PublicationIndex] Cached non-replaceable event with ID (will link to master):', ref.eventId)
} }
} else { } else {
@ -318,24 +460,159 @@ export default function PublicationIndex({
} }
} }
if (fetchedEvent && isMounted) { if (fetchedEvent) {
fetchedRefs.push({ ...ref, event: fetchedEvent }) // Check if this event has nested references we haven't seen yet
} else if (isMounted) { const nestedRefs: PublicationReference[] = []
const identifier = ref.type === 'a' ? ref.coordinate : ref.eventId for (const tag of fetchedEvent.tags) {
logger.warn('[PublicationIndex] Could not fetch event for:', identifier || 'unknown') if (tag[0] === 'a' && tag[1]) {
fetchedRefs.push({ ...ref, event: undefined }) const [kindStr, pubkey, identifier] = tag[1].split(':')
const kind = parseInt(kindStr)
if (!isNaN(kind)) {
const coordinate = tag[1]
const nestedRef: PublicationReference = {
type: 'a',
coordinate,
kind,
pubkey,
identifier: identifier || '',
relay: tag[2],
eventId: tag[3]
}
// Check if we already have this reference
const existingRef = referencesData.find(r =>
r.coordinate === coordinate ||
(r.type === 'a' && r.coordinate === coordinate)
)
if (!existingRef && !currentVisited.has(coordinate)) {
nestedRefs.push(nestedRef)
}
}
} else if (tag[0] === 'e' && tag[1]) {
const eventId = tag[1]
const nestedRef: PublicationReference = {
type: 'e',
eventId,
relay: tag[2]
}
// Check if we already have this reference
const existingRef = referencesData.find(r =>
r.eventId === eventId ||
(r.type === 'e' && r.eventId === eventId)
)
if (!existingRef) {
nestedRefs.push(nestedRef)
}
}
}
return { ...ref, event: fetchedEvent, nestedRefs }
} else {
return { ...ref, event: undefined }
} }
} catch (error) { } catch (error) {
logger.error('[PublicationIndex] Error fetching reference:', error) logger.error('[PublicationIndex] Error fetching reference:', error)
return { ...ref, event: undefined }
}
}, [referencesData])
// Fetch referenced events
useEffect(() => {
let isMounted = true
const fetchReferences = async (isManualRetry = false) => {
if (isManualRetry) {
setIsRetrying(true)
} else {
setIsLoading(true)
}
const fetchedRefs: PublicationReference[] = []
const failedRefs: PublicationReference[] = []
const discoveredRefs: PublicationReference[] = []
// Capture current visitedIndices at the start of the fetch
const currentVisited = visitedIndices
// Add a timeout to prevent infinite loading on mobile
const timeout = setTimeout(() => {
if (isMounted) { if (isMounted) {
fetchedRefs.push({ ...ref, event: undefined }) logger.warn('[PublicationIndex] Fetch timeout reached, setting loaded state')
setIsLoading(false)
setIsRetrying(false)
}
}, 30000) // 30 second timeout
try {
// Combine original references with failed references if this is a retry
const refsToFetch = isManualRetry && failedReferences.length > 0
? [...referencesData, ...failedReferences]
: referencesData
for (const ref of refsToFetch) {
if (!isMounted) break
const result = await fetchSingleReference(ref, currentVisited, isManualRetry)
if (!isMounted) break
if (result) {
if (result.event) {
fetchedRefs.push(result)
// Collect discovered nested references
if ((result as any).nestedRefs && (result as any).nestedRefs.length > 0) {
for (const nestedRef of (result as any).nestedRefs) {
// Check if we already have this reference
const existingRef = fetchedRefs.find(r =>
(r.coordinate && nestedRef.coordinate && r.coordinate === nestedRef.coordinate) ||
(r.eventId && nestedRef.eventId && r.eventId === nestedRef.eventId)
)
if (!existingRef && !discoveredRefs.find(r =>
(r.coordinate && nestedRef.coordinate && r.coordinate === nestedRef.coordinate) ||
(r.eventId && nestedRef.eventId && r.eventId === nestedRef.eventId)
)) {
discoveredRefs.push(nestedRef)
}
}
}
} else {
// Failed to fetch
failedRefs.push(result)
fetchedRefs.push(result)
}
}
}
// Fetch discovered nested references
if (discoveredRefs.length > 0 && isMounted) {
logger.info('[PublicationIndex] Found', discoveredRefs.length, 'new nested references')
for (const nestedRef of discoveredRefs) {
if (!isMounted) break
const result = await fetchSingleReference(nestedRef, currentVisited, isManualRetry)
if (!isMounted) break
if (result) {
if (result.event) {
fetchedRefs.push(result)
} else {
failedRefs.push(result)
fetchedRefs.push(result)
}
} }
} }
} }
if (isMounted) { if (isMounted) {
setReferences(fetchedRefs) setReferences(fetchedRefs)
setFailedReferences(failedRefs.filter(ref => !ref.event))
setIsLoading(false) setIsLoading(false)
setIsRetrying(false)
// Store master publication with all nested events // Store master publication with all nested events
const nestedEvents = fetchedRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined) const nestedEvents = fetchedRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined)
@ -345,13 +622,31 @@ export default function PublicationIndex({
}) })
} }
} }
} catch (error) {
logger.error('[PublicationIndex] Error in fetchReferences:', error)
if (isMounted) {
setIsLoading(false)
setIsRetrying(false)
}
} finally { } finally {
clearTimeout(timeout) clearTimeout(timeout)
} }
} }
if (referencesData.length > 0) { if (referencesData.length > 0) {
fetchReferences() fetchReferences(false).then(() => {
// Auto-retry failed references after initial load
setFailedReferences(prevFailed => {
if (prevFailed.length > 0 && retryCount < maxRetries) {
const delay = Math.min(1000 * Math.pow(2, retryCount), 10000) // Exponential backoff, max 10s
setTimeout(() => {
setRetryCount(prev => prev + 1)
fetchReferences(true)
}, delay)
}
return prevFailed
})
})
} else { } else {
setIsLoading(false) setIsLoading(false)
} }
@ -359,7 +654,129 @@ export default function PublicationIndex({
return () => { return () => {
isMounted = false isMounted = false
} }
}, [referencesData, visitedIndices]) // Now include visitedIndices but capture it inside }, [referencesData, visitedIndices, fetchSingleReference]) // Include fetchSingleReference in dependencies
// Manual retry function
const handleManualRetry = useCallback(async () => {
setRetryCount(0)
setIsRetrying(true)
const fetchReferences = async () => {
const updatedRefs: Map<string, PublicationReference> = new Map()
const newRefs: PublicationReference[] = []
const failedRefs: PublicationReference[] = []
const discoveredRefs: PublicationReference[] = []
const currentVisited = visitedIndices
// Create a map of existing references for quick lookup
references.forEach(ref => {
const id = ref.coordinate || ref.eventId || ''
if (id) {
updatedRefs.set(id, ref)
}
})
// Only retry failed references, not all references
const refsToRetry = failedReferences.length > 0 ? failedReferences : references.filter(ref => !ref.event)
if (refsToRetry.length === 0) {
setIsRetrying(false)
return
}
logger.info('[PublicationIndex] Retrying', refsToRetry.length, 'failed references')
for (const ref of refsToRetry) {
const result = await fetchSingleReference(ref, currentVisited, true)
if (result) {
const id = result.coordinate || result.eventId || ''
if (result.event) {
// Successfully fetched - update existing reference or add new one
if (id) {
updatedRefs.set(id, result)
} else {
newRefs.push(result)
}
// Collect discovered nested references
if ((result as any).nestedRefs && (result as any).nestedRefs.length > 0) {
for (const nestedRef of (result as any).nestedRefs) {
const nestedId = nestedRef.coordinate || nestedRef.eventId || ''
if (!nestedId) continue
// Check if we already have this reference
const existingInMap = updatedRefs.has(nestedId)
const existingInNew = newRefs.find(r => {
const rid = r.coordinate || r.eventId || ''
return rid === nestedId
})
const existingInDiscovered = discoveredRefs.find(r => {
const rid = r.coordinate || r.eventId || ''
return rid === nestedId
})
if (!existingInMap && !existingInNew && !existingInDiscovered) {
discoveredRefs.push(nestedRef)
}
}
}
} else {
// Still failed
if (id) {
updatedRefs.set(id, result)
} else {
failedRefs.push(result)
}
}
}
}
// Fetch discovered nested references
if (discoveredRefs.length > 0) {
logger.info('[PublicationIndex] Found', discoveredRefs.length, 'new nested references on retry')
for (const nestedRef of discoveredRefs) {
const result = await fetchSingleReference(nestedRef, currentVisited, true)
if (result) {
const id = result.coordinate || result.eventId || ''
if (result.event) {
if (id) {
updatedRefs.set(id, result)
} else {
newRefs.push(result)
}
} else {
if (id) {
updatedRefs.set(id, result)
} else {
failedRefs.push(result)
}
}
}
}
}
// Update state with merged results
const finalRefs = Array.from(updatedRefs.values()).concat(newRefs)
const stillFailed = finalRefs.filter(ref => !ref.event)
setReferences(finalRefs)
setFailedReferences(stillFailed)
setIsRetrying(false)
// Store master publication with all nested events
const nestedEvents = finalRefs.filter(ref => ref.event).map(ref => ref.event!).filter((e): e is Event => e !== undefined)
if (nestedEvents.length > 0) {
indexedDb.putPublicationWithNestedEvents(event, nestedEvents).catch(err => {
logger.error('[PublicationIndex] Error caching publication with nested events:', err)
})
}
}
await fetchReferences()
}, [failedReferences, visitedIndices, fetchSingleReference, references, event])
return ( return (
<div className={cn('space-y-6', className)}> <div className={cn('space-y-6', className)}>
@ -422,6 +839,26 @@ export default function PublicationIndex({
</div> </div>
)} )}
{/* Failed References Banner */}
{!isLoading && failedReferences.length > 0 && references.length > 0 && (
<div className="p-4 border rounded-lg bg-yellow-50 dark:bg-yellow-900/20 border-yellow-200 dark:border-yellow-800">
<div className="flex items-center justify-between gap-4">
<div className="text-sm text-yellow-800 dark:text-yellow-200">
{failedReferences.length} reference{failedReferences.length !== 1 ? 's' : ''} failed to load. Click retry to attempt loading again.
</div>
<Button
variant="outline"
size="sm"
onClick={handleManualRetry}
disabled={isRetrying}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRetrying && "animate-spin")} />
Retry All
</Button>
</div>
</div>
)}
{/* Content - render referenced events */} {/* Content - render referenced events */}
{isLoading ? ( {isLoading ? (
<div className="text-muted-foreground"> <div className="text-muted-foreground">
@ -431,18 +868,78 @@ export default function PublicationIndex({
) : references.length === 0 ? ( ) : references.length === 0 ? (
<div className="p-6 border rounded-lg bg-muted/30 text-center"> <div className="p-6 border rounded-lg bg-muted/30 text-center">
<div className="text-lg font-semibold mb-2">No content loaded</div> <div className="text-lg font-semibold mb-2">No content loaded</div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground mb-4">
Unable to load publication content. The referenced events may not be available on the current relays. Unable to load publication content. The referenced events may not be available on the current relays.
</div> </div>
<Button
variant="outline"
onClick={handleManualRetry}
disabled={isRetrying}
>
<RefreshCw className={cn("h-4 w-4 mr-2", isRetrying && "animate-spin")} />
Retry Loading
</Button>
</div> </div>
) : ( ) : (
<div className="space-y-8"> <div className="space-y-8">
{references.map((ref, index) => { {references.map((ref, index) => {
if (!ref.event) { if (!ref.event) {
// Generate naddr from coordinate or eventId for link
let notesLink: string | null = null
if (ref.coordinate) {
const aTag = ['a', ref.coordinate, ref.relay || '', ref.eventId || '']
const bech32Id = generateBech32IdFromATag(aTag)
if (bech32Id) {
// Construct URL as /notes?events=naddr1...
notesLink = `/notes?events=${encodeURIComponent(bech32Id)}`
}
} else if (ref.eventId) {
// For event IDs, try to construct a note/nevent, otherwise use as-is
if (ref.eventId.startsWith('note1') || ref.eventId.startsWith('nevent1') || ref.eventId.startsWith('naddr1')) {
notesLink = `/notes?events=${encodeURIComponent(ref.eventId)}`
} else if (/^[0-9a-f]{64}$/i.test(ref.eventId)) {
// Hex event ID - try to create nevent
try {
const nevent = nip19.neventEncode({ id: ref.eventId })
notesLink = `/notes?events=${encodeURIComponent(nevent)}`
} catch {
// Fallback to hex ID
notesLink = `/notes?events=${encodeURIComponent(ref.eventId)}`
}
}
}
return ( return (
<div key={index} className="p-4 border rounded-lg bg-muted/50"> <div key={index} className="p-4 border rounded-lg bg-muted/50">
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Reference {index + 1}: Unable to load event {ref.coordinate || ref.eventId || 'unknown'} Reference {index + 1}: Unable to load event{' '}
{notesLink ? (
<a
href={notesLink}
onClick={(e) => {
e.preventDefault()
e.stopPropagation()
push(notesLink!)
}}
className="text-primary hover:underline cursor-pointer"
>
{ref.coordinate || ref.eventId || 'unknown'}
</a>
) : (
<span>{ref.coordinate || ref.eventId || 'unknown'}</span>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={handleManualRetry}
disabled={isRetrying}
className="shrink-0"
>
<RefreshCw className={cn("h-4 w-4", isRetrying && "animate-spin")} />
Retry
</Button>
</div> </div>
</div> </div>
) )

80
src/services/client.service.ts

@ -1,4 +1,4 @@
import { BIG_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants'
import { import {
compareEvents, compareEvents,
getReplaceableCoordinate, getReplaceableCoordinate,
@ -942,14 +942,90 @@ class ClientService extends EventTarget {
} else if (!relayUrls.length && !alreadyFetchedFromBigRelays) { } else if (!relayUrls.length && !alreadyFetchedFromBigRelays) {
relayUrls = BIG_RELAY_URLS relayUrls = BIG_RELAY_URLS
} }
if (!relayUrls.length) {
// Final fallback to searchable relays
relayUrls = SEARCHABLE_RELAY_URLS
}
if (!relayUrls.length) return if (!relayUrls.length) return
const events = await this.query(relayUrls, filter) const events = await this.query(relayUrls, filter)
return events.sort((a, b) => b.created_at - a.created_at)[0] return events.sort((a, b) => b.created_at - a.created_at)[0]
} }
/**
* Get user's favorite relays from kind 10012 event
*/
private async getUserFavoriteRelays(): Promise<string[]> {
if (!this.pubkey) return []
try {
const favoriteRelaysEvent = await this.fetchReplaceableEvent(this.pubkey, ExtendedKind.FAVORITE_RELAYS)
if (!favoriteRelaysEvent) return []
const relays: string[] = []
favoriteRelaysEvent.tags.forEach(([tagName, tagValue]) => {
if (tagName === 'relay' && tagValue && isWebsocketUrl(tagValue)) {
const normalizedUrl = normalizeUrl(tagValue)
if (normalizedUrl && !relays.includes(normalizedUrl)) {
relays.push(normalizedUrl)
}
}
})
return relays
} catch (error) {
console.warn('[ClientService] Error fetching user favorite relays:', error)
return []
}
}
/**
* Build initial relay list for fetching events
* Priority: FAST_READ_RELAY_URLS, user's favorite relays (10012), user's relay list read relays (10002) including cache relays (10432)
* All relays are normalized and deduplicated
*/
private async buildInitialRelayList(): Promise<string[]> {
const relaySet = new Set<string>()
// Add FAST_READ_RELAY_URLS
FAST_READ_RELAY_URLS.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relaySet.add(normalized)
})
// Add user's favorite relays (kind 10012)
if (this.pubkey) {
const favoriteRelays = await this.getUserFavoriteRelays()
favoriteRelays.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relaySet.add(normalized)
})
// Add user's relay list read relays (kind 10002) and cache relays (kind 10432)
// fetchRelayList already merges cache relays with regular relay list
try {
const relayList = await this.fetchRelayList(this.pubkey)
if (relayList?.read) {
relayList.read.forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) relaySet.add(normalized)
})
}
} catch (error) {
console.warn('[ClientService] Error fetching user relay list:', error)
}
}
// Return deduplicated array (normalization already handled, Set ensures deduplication)
return Array.from(relaySet)
}
private async fetchEventsFromBigRelays(ids: readonly string[]) { private async fetchEventsFromBigRelays(ids: readonly string[]) {
const events = await this.query(BIG_RELAY_URLS, { // Use optimized initial relay list instead of BIG_RELAY_URLS
const initialRelays = await this.buildInitialRelayList()
const relayUrls = initialRelays.length > 0 ? initialRelays : BIG_RELAY_URLS
const events = await this.query(relayUrls, {
ids: Array.from(new Set(ids)), ids: Array.from(new Set(ids)),
limit: ids.length limit: ids.length
}) })

Loading…
Cancel
Save