You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

384 lines
13 KiB

import { Skeleton } from '@/components/ui/skeleton'
import { BIG_RELAY_URLS, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants'
import { useFetchEvent } from '@/hooks'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import { useTranslation } from 'react-i18next'
import { useEffect, useState } from 'react'
import { Event, nip19 } from 'nostr-tools'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
import { Button } from '../ui/button'
import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent'
import { Search } from 'lucide-react'
import logger from '@/lib/logger'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { contentParserService } from '@/services/content-parser.service'
import { useSmartNoteNavigation } from '@/PageManager'
import { toNote } from '@/lib/link'
export function EmbeddedNote({ noteId, className }: { noteId: string; className?: string }) {
const { event, isFetching } = useFetchEvent(noteId)
const [retryEvent, setRetryEvent] = useState<Event | undefined>(undefined)
const [isRetrying, setIsRetrying] = useState(false)
const [retryCount, setRetryCount] = useState(0)
const maxRetries = 3
// If the first fetch fails, try a force retry (max 3 attempts)
useEffect(() => {
if (!isFetching && !event && !isRetrying && retryCount < maxRetries) {
setIsRetrying(true)
setRetryCount(prev => prev + 1)
client.fetchEventForceRetry(noteId)
.then((retryResult: any) => {
if (retryResult) {
setRetryEvent(retryResult)
}
})
.catch((error: any) => {
logger.warn('EmbeddedNote retry failed', {
attempt: retryCount + 1,
maxRetries,
noteId,
error
})
})
.finally(() => {
setIsRetrying(false)
})
}
}, [isFetching, event, noteId, isRetrying, retryCount])
const finalEvent = event || retryEvent
const finalIsFetching = isFetching || (isRetrying && retryCount <= maxRetries)
if (finalIsFetching) {
return <EmbeddedNoteSkeleton className={className} />
}
if (!finalEvent) {
return <EmbeddedNoteNotFound className={className} noteId={noteId} onEventFound={setRetryEvent} />
}
// Check if this event has bookstr tags (at least "book" tag)
const bookMetadata = extractBookMetadata(finalEvent)
const hasBookstrTags = !!bookMetadata.book
// If it has bookstr tags, render directly as bookstr content (no need to search)
if (hasBookstrTags) {
return (
<div data-embedded-note data-bookstr onClick={(e) => e.stopPropagation()}>
<EmbeddedBookstrEvent event={finalEvent} originalNoteId={noteId} className={className} />
</div>
)
}
// NIP-52 calendar event (scheduled video call) – render as calendar card
if (finalEvent.kind === ExtendedKind.CALENDAR_EVENT_TIME || finalEvent.kind === ExtendedKind.CALENDAR_EVENT_DATE) {
return (
<div data-embedded-note onClick={(e) => e.stopPropagation()}>
<EmbeddedCalendarEvent event={finalEvent} className={className} />
</div>
)
}
// Otherwise, render as regular embedded note
return (
<div data-embedded-note onClick={(e) => e.stopPropagation()}>
<MainNoteCard
className={cn('w-full', className)}
event={finalEvent}
embedded
originalNoteId={noteId}
/>
</div>
)
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div
className={cn('text-left p-2 sm:p-3 border rounded-lg', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
)
}
function EmbeddedNoteNotFound({
noteId,
className,
onEventFound
}: {
noteId: string
className?: string
onEventFound?: (event: Event) => void
}) {
const { t } = useTranslation()
const [isSearchingExternal, setIsSearchingExternal] = useState(false)
const [triedExternal, setTriedExternal] = useState(false)
const [externalRelays, setExternalRelays] = useState<string[]>([])
const [hexEventId, setHexEventId] = useState<string | null>(null)
// Calculate which external relays would be tried
useEffect(() => {
const getExternalRelays = async () => {
// Get all relays that have already been tried (BIG_RELAY_URLS + FAST_READ_RELAY_URLS)
// These are the relays used in the initial fetch
const alreadyTriedRelaysSet = new Set<string>()
;[...BIG_RELAY_URLS, ...FAST_READ_RELAY_URLS].forEach(url => {
const normalized = normalizeUrl(url)
if (normalized) alreadyTriedRelaysSet.add(normalized)
})
let hintRelays: string[] = []
let extractedHexEventId: string | null = null
// Parse relay hints and author from bech32 ID
if (!/^[0-9a-f]{64}$/.test(noteId)) {
try {
const { type, data } = nip19.decode(noteId)
if (type === 'nevent') {
extractedHexEventId = data.id
if (data.relays) hintRelays.push(...data.relays)
if (data.author) {
const authorRelayList = await client.fetchRelayList(data.author)
hintRelays.push(...authorRelayList.write.slice(0, 6))
}
} else if (type === 'naddr') {
if (data.relays) hintRelays.push(...data.relays)
const authorRelayList = await client.fetchRelayList(data.pubkey)
hintRelays.push(...authorRelayList.write.slice(0, 6))
} else if (type === 'note') {
extractedHexEventId = data
}
} catch (err) {
logger.error('Failed to parse external relays', { error: err, noteId })
}
} else {
extractedHexEventId = noteId
}
setHexEventId(extractedHexEventId)
// Get relays where this event was seen
const seenOn = extractedHexEventId ? client.getSeenEventRelayUrls(extractedHexEventId) : []
hintRelays.push(...seenOn)
// Normalize all hint relays
const normalizedHints = hintRelays
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Combine hints with SEARCHABLE_RELAY_URLS (always include as fallback)
// Normalize SEARCHABLE_RELAY_URLS for comparison
const normalizedSearchableRelays = SEARCHABLE_RELAY_URLS
.map(url => normalizeUrl(url))
.filter((url): url is string => Boolean(url))
// Combine all potential relays (hints + searchable)
const allPotentialRelays = new Set([...normalizedHints, ...normalizedSearchableRelays])
// Filter out relays that were already tried
const externalRelays = Array.from(allPotentialRelays).filter(
relay => !alreadyTriedRelaysSet.has(relay)
)
// Deduplicate final relay list
setExternalRelays(externalRelays)
logger.debug('External relays calculated', {
noteId,
hintRelaysCount: normalizedHints.length,
searchableRelaysCount: normalizedSearchableRelays.length,
alreadyTriedCount: alreadyTriedRelaysSet.size,
externalRelaysCount: externalRelays.length,
externalRelays: externalRelays.slice(0, 10) // Log first 10
})
}
getExternalRelays()
}, [noteId])
const handleTryExternalRelays = async () => {
if (!hexEventId || isSearchingExternal) return
if (externalRelays.length === 0) {
logger.warn('No external relays to search', { noteId, hexEventId })
setTriedExternal(true)
return
}
setIsSearchingExternal(true)
try {
logger.info('Searching external relays', {
noteId,
hexEventId,
relayCount: externalRelays.length,
relays: externalRelays.slice(0, 5) // Log first 5 relays
})
const event = await client.fetchEventWithExternalRelays(hexEventId, externalRelays)
if (event) {
logger.info('Event found on external relay', { noteId, hexEventId })
if (onEventFound) {
onEventFound(event)
}
} else {
logger.info('Event not found on external relays', {
noteId,
hexEventId,
relayCount: externalRelays.length
})
}
} catch (error) {
logger.error('External relay fetch failed', { error, noteId, hexEventId, externalRelays })
} finally {
setIsSearchingExternal(false)
setTriedExternal(true)
}
}
const hasExternalRelays = externalRelays.length > 0
return (
<div className={cn('text-left p-3 border rounded-lg', className)}>
<div className="flex flex-col items-center text-muted-foreground gap-3">
<div className="text-sm font-medium">{t('Note not found')}</div>
{!triedExternal && hasExternalRelays && (
<div className="flex flex-col items-center gap-2 w-full">
<Button
variant="outline"
size="sm"
onClick={handleTryExternalRelays}
disabled={isSearchingExternal}
className="gap-2 w-full"
>
{isSearchingExternal ? (
<>
<Search className="w-4 h-4 animate-spin" />
{t('Searching...')}
</>
) : (
<>
<Search className="w-4 h-4" />
{t('Try external relays')} ({externalRelays.length})
</>
)}
</Button>
<details className="text-xs text-muted-foreground w-full">
<summary className="cursor-pointer hover:text-foreground text-center list-none">
{t('Show relays')}
</summary>
<div className="mt-2 space-y-1 max-h-24 overflow-y-auto">
{externalRelays.map((relay, i) => (
<div key={i} className="font-mono text-[10px] truncate px-2 py-0.5 bg-muted/50 rounded">
{relay}
</div>
))}
</div>
</details>
</div>
)}
{!triedExternal && !hasExternalRelays && (
<div className="text-xs text-center">{t('No external relay hints available')}</div>
)}
{triedExternal && (
<div className="text-xs text-center">{t('Note could not be found anywhere')}</div>
)}
<ClientSelect className="w-full" originalNoteId={noteId} />
</div>
</div>
)
}
/**
* Render a single bookstr event directly (no searching needed)
*/
function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Event; originalNoteId?: string; className?: string }) {
const [parsedContent, setParsedContent] = useState<string | null>(null)
const bookMetadata = extractBookMetadata(event)
const { navigateToNote } = useSmartNoteNavigation()
useEffect(() => {
const parseContent = async () => {
try {
const result = await contentParserService.parseContent(event.content, {
eventKind: ExtendedKind.PUBLICATION_CONTENT
})
setParsedContent(result.html)
} catch (err) {
logger.warn('Error parsing bookstr event content', { error: err, eventId: event.id.substring(0, 8) })
setParsedContent(event.content)
}
}
parseContent()
}, [event])
const chapterNum = bookMetadata.chapter
const verseNum = bookMetadata.verse
const version = bookMetadata.version
const bookName = bookMetadata.book
? bookMetadata.book
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ')
: ''
const content = parsedContent || event.content
return (
<div
className={cn('border rounded-lg p-3 bg-muted/30 clickable', className)}
data-event-id={event.id}
onClick={(e) => {
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a')) {
return
}
e.stopPropagation()
// Navigate to the note view
const noteUrl = toNote(originalNoteId ?? event)
navigateToNote(noteUrl)
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<h4 className="font-semibold text-sm">
{bookName}
{chapterNum && ` ${chapterNum}`}
{verseNum && `:${verseNum}`}
{version && ` (${version.toUpperCase()})`}
</h4>
</div>
{/* Content */}
<div className="flex gap-2 text-sm leading-relaxed items-baseline">
{/* Verse number on the left - only show verse number, not chapter:verse */}
<span className="font-semibold text-muted-foreground shrink-0 min-w-[2.5rem] text-right">
{verseNum || null}
</span>
{/* Content on the right */}
<span className="flex-1" dangerouslySetInnerHTML={{ __html: content }} />
</div>
</div>
)
}