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
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> |
|
) |
|
} |
|
} |
|
|
|
|