import { useMemo, useState, useCallback } from 'react'; import { type NostrEvent } from '@nostrify/nostrify'; import { Link } from 'react-router-dom'; import { nip19 } from 'nostr-tools'; import { useAuthor } from '@/hooks/useAuthor'; import { genUserName } from '@/lib/genUserName'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { LinkPreview } from '@/components/LinkPreview'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { YouTubeEmbed } from '@/components/YouTubeEmbed'; import { Lightbox } from '@/components/ImageGallery'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { buildEmojiMap, emojify, EmojifiedText } from '@/components/CustomEmoji'; import { useBlossomFallback } from '@/hooks/useBlossomFallback'; import { cn } from '@/lib/utils'; import type { AddrCoords } from '@/hooks/useEvent'; interface NoteContentProps { event: NostrEvent; className?: string; /** When true, renders URLs as inline links instead of link preview cards / embeds. */ disableEmbeds?: boolean; } /** Regex to detect image URLs that should be rendered inline. */ const IMAGE_URL_REGEX = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|avif)(\?[^\s]*)?/i; /** Regex to detect video/webxdc URLs rendered as embeds by the parent component. */ const MEDIA_URL_REGEX = /https?:\/\/[^\s]+\.(mp4|webm|mov|xdc)(\?[^\s]*)?/i; /** Extract a YouTube video ID from a URL, or null if not a YouTube link. */ function extractYouTubeId(url: string): string | null { try { const u = new URL(url); // youtube.com/watch?v=ID if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') { return u.searchParams.get('v'); } // youtube.com/embed/ID if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) { return u.pathname.split('/')[2] || null; } // youtube.com/shorts/ID if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) { return u.pathname.split('/')[2] || null; } // youtu.be/ID if (u.hostname === 'youtu.be') { return u.pathname.slice(1) || null; } } catch { // not a valid URL } return null; } /** Bech32 charset used by NIP-19 identifiers. */ const BECH32_CHARS = '023456789acdefghjklmnpqrstuvwxyz'; /** Regex to extract an naddr1 identifier from a URL path. */ const NADDR_IN_URL_REGEX = new RegExp(`naddr1[${BECH32_CHARS}]{10,}`, 'i'); /** Try to extract naddr coordinates from a URL containing an naddr1 identifier. */ function extractNaddrFromUrl(url: string): AddrCoords | null { const match = url.match(NADDR_IN_URL_REGEX); if (!match) return null; try { const decoded = nip19.decode(match[0]); if (decoded.type === 'naddr') { return decoded.data as AddrCoords; } } catch { // invalid naddr } return null; } /** A parsed token from note content. */ type ContentToken = | { type: 'text'; value: string } | { type: 'image-embed'; url: string } | { type: 'link-preview'; url: string } | { type: 'inline-link'; url: string } | { type: 'youtube-embed'; videoId: string } | { type: 'mention'; pubkey: string } | { type: 'nevent-embed'; eventId: string } | { type: 'naddr-embed'; addr: AddrCoords; url?: string } | { type: 'nostr-link'; id: string; raw: string } | { type: 'hashtag'; tag: string; raw: string }; /** Check if a string contains only emojis (and whitespace). */ function isOnlyEmojis(text: string): boolean { // Remove whitespace const trimmed = text.replace(/\s/g, ''); if (trimmed.length === 0) return false; // Emoji regex - matches emoji characters including skin tone modifiers and ZWJ sequences // This is a simplified version - for production you might want a more comprehensive regex const emojiRegex = /^(\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+$/u; return emojiRegex.test(trimmed) && trimmed.length <= 12; // Max 12 emoji chars for enlarged display } /** Parses content of text note events so that URLs and hashtags are linkified. */ export function NoteContent({ event, className, disableEmbeds = false, }: NoteContentProps) { const tokens = useMemo(() => { const text = event.content; // Match: URLs | nostr:-prefixed NIP-19 ids | @-prefixed or bare NIP-19 ids | hashtags // NIP-19 ids can appear anywhere (with optional @ prefix that gets consumed) const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#\w+)/g; const result: ContentToken[] = []; let lastIndex = 0; let match: RegExpExecArray | null; let hadMatches = false; while ((match = regex.exec(text)) !== null) { let [fullMatch, url] = match; const hashtag = match[6]; const { 2: nostrPrefix, 3: nostrData, 4: barePrefix, 5: bareData } = match; const index = match.index; hadMatches = true; // Add text before this match if (index > lastIndex) { result.push({ type: 'text', value: text.substring(lastIndex, index) }); } if (url) { // Strip common trailing punctuation that's likely not part of the URL // This handles cases like "(https://example.com)" or "Check this: https://example.com." const trailingPunctMatch = url.match(/^(.*?)([.,;:!?)\]]+)$/); if (trailingPunctMatch) { const [, urlWithoutPunct] = trailingPunctMatch; // Only strip the punctuation if the URL without it is still valid if (urlWithoutPunct && urlWithoutPunct.length > 10) { url = urlWithoutPunct; fullMatch = urlWithoutPunct; // The punctuation will be part of the next text token } } // Image URLs → render inline at their position in the text if (IMAGE_URL_REGEX.test(url)) { if (result.length > 0) { const prev = result[result.length - 1]; if (prev.type === 'text') { prev.value = prev.value.replace(/\s+$/, ''); } } result.push({ type: 'image-embed', url }); lastIndex = index + fullMatch.length; // Strip leading whitespace that follows the image URL const remaining = text.substring(lastIndex); const leadingWs = remaining.match(/^\s+/); if (leadingWs) { lastIndex += leadingWs[0].length; } continue; } // Skip non-image media URLs — rendered as embedded media by the parent. if (MEDIA_URL_REGEX.test(url)) { if (result.length > 0) { const prev = result[result.length - 1]; if (prev.type === 'text') { prev.value = prev.value.replace(/\s+$/, ''); } } lastIndex = index + fullMatch.length; // Also strip leading whitespace that follows the skipped URL const remaining = text.substring(lastIndex); const leadingWs = remaining.match(/^\s+/); if (leadingWs) { lastIndex += leadingWs[0].length; } continue; } // Determine if this URL ends a line (not followed by more text). // A URL gets a preview card when nothing meaningful follows it on // the same line (i.e., it ends the line or the content). const afterUrl = text.substring(index + fullMatch.length); const nextNewline = afterUrl.indexOf('\n'); const lineSuffix = nextNewline === -1 ? afterUrl : afterUrl.substring(0, nextNewline); const isEndOfLine = lineSuffix.trim() === ''; // Check if the URL contains an naddr1 identifier → embed as Nostr event + preserve link const naddrFromUrl = extractNaddrFromUrl(url); if (naddrFromUrl) { result.push({ type: 'naddr-embed', addr: naddrFromUrl, url }); } else if (isEndOfLine) { // YouTube → playable embed const ytId = extractYouTubeId(url); if (ytId) { result.push({ type: 'youtube-embed', videoId: ytId }); } else { // Standalone URL → link preview card result.push({ type: 'link-preview', url }); } } else { // Inline URL mid-sentence → plain clickable link result.push({ type: 'inline-link', url }); } } else if ((nostrPrefix && nostrData) || (barePrefix && bareData)) { // Handle both nostr:-prefixed and bare NIP-19 identifiers const prefix = nostrPrefix || barePrefix; const data = nostrData || bareData; try { const nostrId = `${prefix}${data}`; const decoded = nip19.decode(nostrId); if (decoded.type === 'npub') { result.push({ type: 'mention', pubkey: decoded.data }); } else if (decoded.type === 'nprofile') { result.push({ type: 'mention', pubkey: decoded.data.pubkey }); } else if (decoded.type === 'note') { result.push({ type: 'nevent-embed', eventId: decoded.data as string }); } else if (decoded.type === 'nevent') { result.push({ type: 'nevent-embed', eventId: (decoded.data as { id: string }).id }); } else if (decoded.type === 'naddr') { result.push({ type: 'naddr-embed', addr: decoded.data as AddrCoords }); } else { result.push({ type: 'nostr-link', id: nostrId, raw: fullMatch }); } } catch { result.push({ type: 'text', value: fullMatch }); } } else if (hashtag) { const tag = hashtag.slice(1); result.push({ type: 'hashtag', tag, raw: hashtag }); } lastIndex = index + fullMatch.length; } // Add any remaining text if (lastIndex < text.length) { result.push({ type: 'text', value: text.substring(lastIndex) }); } if (result.length === 0 && !hadMatches) { result.push({ type: 'text', value: text }); } // Collapse excessive whitespace around block-level tokens (link-preview, youtube-embed) // Preserve formatting but prevent too much stacking with the card's own spacing. for (let i = 0; i < result.length; i++) { const token = result[i]; const isBlock = token.type === 'image-embed' || token.type === 'link-preview' || token.type === 'youtube-embed' || token.type === 'nevent-embed' || (token.type === 'naddr-embed' && !token.url); if (isBlock) { // Strip all trailing whitespace from the preceding text token. // The block's own margin (my-2.5) handles spacing, so preserved // newlines just add redundant blank lines under whitespace-pre-wrap. if (i > 0) { const prev = result[i - 1]; if (prev.type === 'text') { prev.value = prev.value.replace(/\s+$/, ''); } } // Strip all leading whitespace from the following text token. if (i < result.length - 1) { const next = result[i + 1]; if (next.type === 'text') { next.value = next.value.replace(/^\s+/, ''); } } } } // Trim leading/trailing whitespace from edge text tokens if (result.length > 0) { const first = result[0]; if (first.type === 'text') { first.value = first.value.replace(/^\s+/, ''); } const last = result[result.length - 1]; if (last.type === 'text') { last.value = last.value.replace(/\s+$/, ''); } } // Filter out empty text tokens return result.filter((t) => !(t.type === 'text' && t.value === '')); }, [event]); // Build emoji map for NIP-30 custom emoji rendering const emojiMap = useMemo(() => buildEmojiMap(event.tags), [event.tags]); // Collect all inline image URLs (in order) for the shared lightbox const allImages = useMemo( () => tokens.filter((t): t is { type: 'image-embed'; url: string } => t.type === 'image-embed').map((t) => t.url), [tokens], ); // Shared lightbox state for inline images const [lightboxIndex, setLightboxIndex] = useState(null); const closeLightbox = useCallback(() => setLightboxIndex(null), []); const goNext = useCallback(() => setLightboxIndex((p) => (p !== null ? (p + 1) % allImages.length : null)), [allImages.length]); const goPrev = useCallback(() => setLightboxIndex((p) => (p !== null ? (p - 1 + allImages.length) % allImages.length : null)), [allImages.length]); // Check if content is only emojis (single text token with only emojis) const isEmojiOnly = tokens.length === 1 && tokens[0].type === 'text' && isOnlyEmojis(tokens[0].value); // Build a map from token index → image list index so duplicate URLs get correct positions const tokenImageIndex = useMemo(() => { const map = new Map(); let imgCount = 0; tokens.forEach((t, i) => { if (t.type === 'image-embed') map.set(i, imgCount++); }); return map; }, [tokens]); return (
{tokens.map((token, i) => { switch (token.type) { case 'text': return {emojify(token.value, emojiMap)}; case 'image-embed': { if (disableEmbeds) { // In preview contexts (e.g. triple-dot menu), replace image URLs // with a newline so text flow is preserved without showing raw URLs. return {'\n'}; } const imgIndex = tokenImageIndex.get(i) ?? 0; return ( { e.stopPropagation(); setLightboxIndex(imgIndex); }} /> ); } case 'link-preview': if (disableEmbeds) { return ( e.stopPropagation()} > {token.url} ); } return ; case 'inline-link': return ( e.stopPropagation()} > {token.url} ); case 'youtube-embed': return ; case 'nevent-embed': return ; case 'naddr-embed': return ( {token.url && ( e.stopPropagation()} > {token.url} )} ); case 'mention': return ; case 'nostr-link': return ( e.stopPropagation()} > {token.raw} ); case 'hashtag': return ( e.stopPropagation()} > {token.raw} ); } })} {/* Shared lightbox for all inline images in this post */} {lightboxIndex !== null && ( )}
); } /** Inline image thumbnail that opens the shared lightbox on click. */ function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEvent) => void }) { const { src, onError } = useBlossomFallback(url); return ( ); } function NostrMention({ pubkey }: { pubkey: string }) { const author = useAuthor(pubkey); const hasRealName = !!author.data?.metadata?.name; const displayName = author.data?.metadata?.name ?? genUserName(pubkey); const profileUrl = useProfileUrl(pubkey, author.data?.metadata); return ( e.stopPropagation()} > @{author.data?.event ? ( {displayName} ) : displayName} ); }