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.
 
 
 
 

409 lines
15 KiB

import { Event, kinds } from 'nostr-tools'
import { Highlighter } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import logger from '@/lib/logger'
import HighlightSourcePreview from '@/components/UniversalContent/HighlightSourcePreview'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { useSmartNoteNavigationOptional } from '@/PageManager'
import { toNote } from '@/lib/link'
import { isPseudoNostrHttpsUrl } from '@/lib/url'
import { useFetchEvent } from '@/hooks'
import { useEffect, useState, useMemo } from 'react'
import { ExtendedKind } from '@/constants'
import { resolveNip84HighlightDisplay } from '@/lib/nip84-highlight-display'
function stripOuterQuotes(s: string): string {
let t = s.trim()
if (t.startsWith('"') && t.endsWith('"')) {
t = t.slice(1, -1).trim()
}
return t
}
/**
* Check if a string is a URL or Nostr address
*/
function isUrlOrNostrAddress(value: string | undefined): boolean {
if (!value || typeof value !== 'string') {
return false
}
// Check if it's a URL (http://, https://, or starts with common URL patterns)
try {
if (value.startsWith('http://') || value.startsWith('https://') || value.startsWith('ws://') || value.startsWith('wss://')) {
new URL(value) // Validate it's a proper URL
return true
}
} catch {
// Not a valid URL
}
// Check if it's a Nostr address (nostr: prefix or bech32 encoded)
if (value.startsWith('nostr:')) {
return true
}
// Check if it's a bech32 encoded Nostr address
try {
const decoded = nip19.decode(value)
if (['npub', 'nprofile', 'nevent', 'naddr', 'note', 'nrelay'].includes(decoded.type)) {
return true
}
} catch {
// Not a valid Nostr address
}
return false
}
/**
* Simple author card for highlights with Nostr sources (e-tags, r-tags)
* Shows just "A note from: [user badge]" instead of the full embedded note
* The word "note" is a hyperlink to the referenced event
*/
function HighlightAuthorCard({
authorPubkey,
eventId,
onClick
}: {
authorPubkey: string
eventId?: string
onClick?: () => void
}) {
const { navigateToNote } = useSmartNoteNavigationOptional()
const handleNoteClick = (e: React.MouseEvent) => {
e.stopPropagation()
if (onClick) {
onClick()
} else if (eventId) {
navigateToNote(toNote(eventId))
}
}
return (
<div
className="flex items-center gap-2 p-3 rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-800/50"
>
<span className="text-sm text-muted-foreground">
A{' '}
<button
onClick={handleNoteClick}
className="text-primary hover:text-primary/80 hover:underline font-medium cursor-pointer"
>
note
</button>
{' '}from:
</span>
<UserAvatar userId={authorPubkey} size="small" />
<Username userId={authorPubkey} className="text-sm font-medium" />
</div>
)
}
export default function Highlight({
event,
className
}: {
event: Event
className?: string
}) {
// State for storing the referenced event's author
const [referencedEventAuthor, setReferencedEventAuthor] = useState<string | null>(null)
const [sourceEventId, setSourceEventId] = useState<string | null>(null)
const [sourceBech32, setSourceBech32] = useState<string | null>(null)
try {
// Extract the source (e-tag, a-tag, or r-tag) with improved priority handling
let source = null
let quoteSource: string | null = null // For plain text r-tags that aren't URLs/Nostr addresses
let sourceTag: string[] | undefined
// Check for 'source' marker first (highest priority)
for (const tag of event.tags) {
if (tag[2] === 'source' || tag[3] === 'source') {
sourceTag = tag
break
}
}
// If no 'source' marker found, process tags in priority order: e > a > r
if (!sourceTag) {
for (const tag of event.tags) {
// Give 'e' tags highest priority
if (tag[0] === 'e') {
sourceTag = tag
continue
}
// Give 'a' tags second priority (but don't override 'e' tags)
if (tag[0] === 'a' && (!sourceTag || sourceTag[0] !== 'e')) {
sourceTag = tag
continue
}
// Give 'r' tags lowest priority (skip fake `https://nostr:…` r-tags — not web URLs)
if (tag[0] === 'r' && (!sourceTag || sourceTag[0] === 'r')) {
if (tag[1] && isPseudoNostrHttpsUrl(tag[1])) continue
sourceTag = tag
continue
}
}
}
// Process the selected source tag
// We'll fetch the referenced event to get the author pubkey
let tempSourceEventId: string | null = null // Event ID or bech32 for fetching the event
let tempSourceBech32: string | null = null // Bech32 ID for navigation
if (sourceTag) {
if (sourceTag[0] === 'e' && sourceTag[1]) {
source = {
type: 'event' as const,
value: sourceTag[1],
bech32: nip19.noteEncode(sourceTag[1])
}
tempSourceEventId = sourceTag[1] // Store event ID for fetching
tempSourceBech32 = nip19.noteEncode(sourceTag[1]) // Store bech32 for navigation
} else if (sourceTag[0] === 'a' && sourceTag[1]) {
const [kind, pubkey, identifier] = sourceTag[1].split(':')
const relay = sourceTag[2]
const bech32 = nip19.naddrEncode({
kind: parseInt(kind),
pubkey,
identifier: identifier || '',
relays: relay ? [relay] : []
})
source = {
type: 'addressable' as const,
value: sourceTag[1],
bech32
}
tempSourceEventId = bech32 // Store bech32 for fetching the event
tempSourceBech32 = bech32 // Store bech32 for navigation
} else if (sourceTag[0] === 'r') {
// Ignore fake `https://nostr:…` (invalid https; breaks WebPreview)
if (sourceTag[1] && isPseudoNostrHttpsUrl(sourceTag[1])) {
// no source / no quote card for this tag
} else if (sourceTag[1] && isUrlOrNostrAddress(sourceTag[1])) {
// Try to decode as Nostr address to extract author
try {
const decoded = nip19.decode(sourceTag[1])
if (decoded.type === 'naddr') {
// For naddr, we have the pubkey directly
source = {
type: 'url' as const,
value: sourceTag[1],
bech32: sourceTag[1]
}
} else if (decoded.type === 'nevent') {
// For nevent, we can fetch the event to get the author
tempSourceEventId = sourceTag[1] // Store bech32 for fetching
tempSourceBech32 = sourceTag[1] // Store bech32 for navigation
source = {
type: 'url' as const,
value: sourceTag[1],
bech32: sourceTag[1]
}
} else if (decoded.type === 'note') {
// For note, we can fetch the event to get the author
tempSourceEventId = sourceTag[1] // Store bech32 for fetching
tempSourceBech32 = sourceTag[1] // Store bech32 for navigation
source = {
type: 'url' as const,
value: sourceTag[1],
bech32: sourceTag[1]
}
} else {
// Other Nostr types or URL
source = {
type: 'url' as const,
value: sourceTag[1],
bech32: sourceTag[1]
}
}
} catch {
// Not a valid Nostr address, treat as regular URL
source = {
type: 'url' as const,
value: sourceTag[1],
bech32: sourceTag[1]
}
}
} else if (sourceTag[1]) {
// It's plain text, store it as a quote source
quoteSource = sourceTag[1]
}
}
}
// Update state for fetching the referenced event
useEffect(() => {
if (tempSourceEventId) {
setSourceEventId(tempSourceEventId)
setSourceBech32(tempSourceBech32)
}
}, [tempSourceEventId, tempSourceBech32])
// Fetch the referenced event to get the author pubkey and check if it has a special card
const { event: referencedEvent } = useFetchEvent(sourceEventId || undefined)
// Determine if the referenced event has a special card that should be used instead of simple author card
const hasSpecialCard = useMemo(() => {
// For r-tags that are regular URLs (http/https), they have OpenGraph cards - always use those
if (sourceTag && sourceTag[0] === 'r' && sourceTag[1]) {
if (
(sourceTag[1].startsWith('http://') || sourceTag[1].startsWith('https://')) &&
!isPseudoNostrHttpsUrl(sourceTag[1])
) {
return true // URLs have OpenGraph cards - use full preview
}
}
if (!referencedEvent) {
// For a-tags, check the kind from the tag itself (before event is loaded)
if (sourceTag && sourceTag[0] === 'a' && sourceTag[1]) {
const [kindStr] = sourceTag[1].split(':')
const kind = parseInt(kindStr)
// Longform articles (30023) have their own preview card
if (kind === kinds.LongFormArticle) {
return true
}
}
return false // Don't know yet - wait for event to load
}
// Events with special preview cards that should always use full preview
const specialCardKinds = [
kinds.LongFormArticle, // 30023 — long-form preview card
ExtendedKind.POLL, // Has PollPreview
ExtendedKind.DISCUSSION, // Has DiscussionNote
ExtendedKind.VIDEO, // Has VideoNotePreview
ExtendedKind.SHORT_VIDEO, // Has VideoNotePreview
ExtendedKind.PICTURE, // Has PictureNotePreview
ExtendedKind.PUBLICATION, // Has PublicationCard
ExtendedKind.WIKI_ARTICLE, // Has special card
ExtendedKind.WIKI_ARTICLE_MARKDOWN, // Has special card
ExtendedKind.VOICE, // Has special card
ExtendedKind.VOICE_COMMENT, // Has special card
]
return specialCardKinds.includes(referencedEvent.kind)
}, [referencedEvent, sourceTag])
// Update the author when we get the referenced event
useEffect(() => {
if (referencedEvent) {
setReferencedEventAuthor(referencedEvent.pubkey)
}
}, [referencedEvent])
// For a-tags, we can also extract the pubkey directly from the tag (for immediate display)
useEffect(() => {
if (sourceTag && sourceTag[0] === 'a' && sourceTag[1] && !referencedEventAuthor && !hasSpecialCard) {
const [kindStr, pubkey] = sourceTag[1].split(':')
const kind = parseInt(kindStr)
// Only set author for a-tags that don't have special cards
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey) && kind !== kinds.LongFormArticle) {
setReferencedEventAuthor(pubkey)
}
}
}, [sourceTag, referencedEventAuthor, hasSpecialCard])
const { fullText, markedSpan } = useMemo(
() => resolveNip84HighlightDisplay(event),
[event.id, event.content, event.tags]
)
const markClassName =
'bg-green-200 dark:bg-green-600 dark:text-white px-1 rounded font-medium'
const quotedBody = useMemo(() => {
const cleanFull = stripOuterQuotes(fullText)
const cleanMark = stripOuterQuotes(markedSpan)
if (!cleanFull) return null
if (!cleanMark || cleanFull === cleanMark) {
return (
<mark className={markClassName} data-nip84-highlight="span">
{cleanFull}
</mark>
)
}
const pieces = cleanFull.split(cleanMark)
if (pieces.length === 1) {
return (
<mark className={markClassName} data-nip84-highlight="span">
{cleanFull}
</mark>
)
}
return pieces.map((part, index) => (
<span key={index}>
{part}
{index < pieces.length - 1 && (
<mark className={markClassName} data-nip84-highlight="span">
{cleanMark}
</mark>
)}
</span>
))
}, [fullText, markedSpan])
return (
<div className={`bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${className || ''}`}>
<div className="flex-1 min-w-0">
{/* Full quoted text with highlighted portion (context, textquoteselector, or textpositionselector) */}
{quotedBody && (
<div className="note-content text-base font-normal mb-4 whitespace-pre-wrap break-words border-l-4 border-green-500 pl-5 py-4 leading-relaxed bg-green-50/30 dark:bg-green-950/20 rounded-r-lg">
<div>{quotedBody}</div>
</div>
)}
{/* Quote source (plain text r-tag) */}
{quoteSource && (
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400 italic">
{quoteSource.trimStart().startsWith('—') ? quoteSource : `${quoteSource}`}
</div>
)}
{/* Source preview card */}
{source && (
<div className="mt-3">
{/* Only show simple author card if:
1. We have the author pubkey
2. The referenced event doesn't have a special card (like LongFormArticle preview)
3. For r-tags: only if it's a Nostr address, not a regular URL (URLs have OpenGraph cards)
*/}
{referencedEventAuthor && !hasSpecialCard ? (
<HighlightAuthorCard
authorPubkey={referencedEventAuthor}
eventId={sourceBech32 || undefined}
/>
) : (
// For sources with special cards, URLs with OpenGraph, or while loading, show full preview
<HighlightSourcePreview source={source} className="w-full" />
)}
</div>
)}
</div>
</div>
)
} catch (error) {
logger.error('Highlight component error', { error, eventId: event.id })
return (
<div className={`relative border-l-4 border-red-500 bg-red-50/50 dark:bg-red-950/20 rounded-r-lg p-4 ${className || ''}`}>
<div className="flex items-start gap-3">
<Highlighter className="w-5 h-5 text-red-600 dark:text-red-500 shrink-0 mt-1" />
<div className="flex-1 min-w-0">
<div className="font-bold text-red-800 dark:text-red-200">Highlight Error:</div>
<div className="text-red-700 dark:text-red-300 text-sm">{String(error)}</div>
<div className="mt-2 text-sm">Content: {event.content}</div>
<div className="text-sm">Context: {event.tags.find(tag => tag[0] === 'context')?.[1] || 'No context found'}</div>
</div>
</div>
</div>
)
}
}