import { useMemo, useState, useCallback, type ReactNode } 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 { LinkEmbed } from '@/components/LinkEmbed'; import { EmbeddedNote } from '@/components/EmbeddedNote'; import { EmbeddedNaddr } from '@/components/EmbeddedNaddr'; import { Lightbox, ImageGallery } from '@/components/ImageGallery'; import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { EmojifiedText, CustomEmojiImg } from '@/components/CustomEmoji'; import { buildEmojiMap } from '@/lib/customEmoji'; import { useCustomEmojis } from '@/hooks/useCustomEmojis'; import { useBlossomFallback } from '@/hooks/useBlossomFallback'; import { COUNTRIES } from '@/lib/countries'; import { IMAGE_URL_REGEX, EMBED_MEDIA_URL_REGEX } from '@/lib/mediaUrls'; 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; /** When true, hides thumbnail images in link preview cards (useful when a cover image is already shown). */ hideEmbedImages?: boolean; } /** Regex matching `:shortcode:` patterns in text. */ const SHORTCODE_REGEX = /:([a-zA-Z0-9_-]+):/g; /** * Replaces `:shortcode:` patterns in text with inline custom emoji images. */ function emojify( text: string, emojiMap: Map, imgClassName?: string, ): ReactNode[] { if (emojiMap.size === 0) return [text]; const result: ReactNode[] = []; let lastIndex = 0; let match: RegExpExecArray | null; SHORTCODE_REGEX.lastIndex = 0; while ((match = SHORTCODE_REGEX.exec(text)) !== null) { const [fullMatch, shortcode] = match; const url = emojiMap.get(shortcode); if (!url) continue; if (match.index > lastIndex) { result.push(text.substring(lastIndex, match.index)); } result.push( , ); lastIndex = match.index + fullMatch.length; } if (lastIndex < text.length) { result.push(text.substring(lastIndex)); } return result.length > 0 ? result : [text]; } /** 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; } /** Regex matching flag emoji: pairs of Regional Indicator Symbol letters (U+1F1E6–U+1F1FF). */ const FLAG_EMOJI_REGEX = /([\u{1F1E6}-\u{1F1FF}]{2})/gu; /** * Convert a flag emoji (pair of Regional Indicator Symbols) to an ISO 3166-1 alpha-2 code. * Returns the code if it maps to a known country, otherwise null. */ function flagToCountryCode(flag: string): string | null { const codePoints = [...flag]; if (codePoints.length !== 2) return null; const a = codePoints[0].codePointAt(0)! - 0x1F1E6 + 65; const b = codePoints[1].codePointAt(0)! - 0x1F1E6 + 65; const code = String.fromCharCode(a) + String.fromCharCode(b); return COUNTRIES[code] ? code : null; } /** * Process an array of ReactNodes (from emojify), splitting any string nodes * to wrap flag emojis in elements pointing to /i/iso3166:. */ function linkifyFlags(nodes: ReactNode[]): ReactNode[] { const result: ReactNode[] = []; let keyIdx = 0; for (const node of nodes) { if (typeof node !== 'string') { result.push(node); continue; } let lastIndex = 0; FLAG_EMOJI_REGEX.lastIndex = 0; let match: RegExpExecArray | null; while ((match = FLAG_EMOJI_REGEX.exec(node)) !== null) { const flag = match[1]; const code = flagToCountryCode(flag); if (!code) continue; if (match.index > lastIndex) { result.push(node.substring(lastIndex, match.index)); } result.push( e.stopPropagation()} > {flag} , ); lastIndex = match.index + match[0].length; } if (lastIndex < node.length) { result.push(node.substring(lastIndex)); } else if (lastIndex === 0) { result.push(node); } } return result; } /** A parsed token from note content. */ type ContentToken = | { type: 'text'; value: string } | { type: 'image-embed'; url: string } | { type: 'image-gallery'; urls: string[] } | { type: 'link-embed'; url: string } | { type: 'inline-link'; url: string } | { type: 'mention'; pubkey: string } | { type: 'nevent-embed'; eventId: string; relays?: string[]; author?: string } | { type: 'naddr-embed'; addr: AddrCoords; url?: string } | { type: 'nostr-link'; id: string; raw: string } | { type: 'hashtag'; tag: string; raw: string } | { type: 'relay-link'; url: string }; /** * Regex segment matching a single visual emoji unit, including: * - ZWJ sequences (e.g. πŸ‘¨β€πŸ‘©β€πŸ‘§β€πŸ‘¦, πŸ‘©β€πŸ’») * - Skin tone / hair style modifiers (e.g. πŸ‘‹πŸ½) * - Flag sequences (Regional Indicator pairs, e.g. πŸ‡ΊπŸ‡Έ) * - Keycap sequences (e.g. 1️⃣) * - Tag sequences (e.g. 🏴󠁧󠁒󠁷󠁬󠁳󠁿) * - Basic presentation emojis (with or without VS16) */ const EMOJI_UNIT = [ // ZWJ sequences: emoji (+ optional modifier) joined by ZWJ, repeated '(?:' + '(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)' + // base emoji '[\\u{1F3FB}-\\u{1F3FF}]?' + // optional skin tone '(?:\\u200D(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)+' + // ZWJ + next ')', // Flag sequences (two Regional Indicator symbols) '(?:[\\u{1F1E6}-\\u{1F1FF}]{2})', // Keycap sequences: digit/# /* + VS16 + combining enclosing keycap '(?:[0-9#*]\\uFE0F\\u20E3)', // Tag sequences (subdivision flags): 🏴 + tag chars + cancel tag '(?:\\u{1F3F4}[\\u{E0020}-\\u{E007E}]+\\u{E007F})', // Single emoji with optional skin tone modifier '(?:(?:\\p{Emoji_Presentation}|\\p{Emoji}\\uFE0F)[\\u{1F3FB}-\\u{1F3FF}]?)', ].join('|'); /** NIP-30 custom emoji shortcode pattern. */ const CUSTOM_EMOJI_SHORTCODE = ':([a-zA-Z0-9_-]+):'; /** Regex matching a string of only NIP-30 custom emoji shortcodes and/or unicode emojis (with optional whitespace). Max 10 visual emojis. */ const EMOJI_OR_CUSTOM_ONLY_REGEX = new RegExp( `^\\s*(?:(?:${CUSTOM_EMOJI_SHORTCODE}|${EMOJI_UNIT})\\s*){1,10}$`, 'u', ); /** Check if a string contains only unicode emojis and/or NIP-30 custom emoji shortcodes (and whitespace). */ function isOnlyEmojisOrCustom(text: string, emojiMap: Map): boolean { if (!EMOJI_OR_CUSTOM_ONLY_REGEX.test(text)) return false; // Verify all shortcodes in the text actually resolve to a custom emoji const shortcodeMatches = text.matchAll(/:([a-zA-Z0-9_-]+):/g); for (const m of shortcodeMatches) { if (!emojiMap.has(m[1])) return false; } return true; } /** Parses content of text note events so that URLs and hashtags are linkified. */ export function NoteContent({ event, className, disableEmbeds = false, hideEmbedImages = 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?|wss?):\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|@?(npub1|note1|nprofile1|nevent1|naddr1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#[\p{L}\p{N}_]+)/gu; 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 } } // WebSocket relay URLs β†’ link to internal relay page if (/^wss?:\/\//i.test(url)) { result.push({ type: 'relay-link', url }); lastIndex = index + fullMatch.length; continue; } // 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 (EMBED_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) { // Standalone URL at end of line β†’ rich embed (YouTube, Tweet, or link preview) result.push({ type: 'link-embed', 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.id, relays: decoded.data.relays, author: decoded.data.author, }); } 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 }); } // Enrich nevent-embed tokens with relay hints from `q` tags when the // nevent URI itself didn't include any. This ensures quote posts whose // inline nevent was encoded without relay hints can still resolve the // quoted event via the relay hint stored in the q tag. const qTagMap = new Map(); for (const tag of event.tags) { if (tag[0] === 'q' && tag[1]) { qTagMap.set(tag[1], { relay: tag[2] || undefined, author: tag[3] || undefined }); } } if (qTagMap.size > 0) { for (const token of result) { if (token.type === 'nevent-embed') { const qInfo = qTagMap.get(token.eventId); if (qInfo) { if ((!token.relays || token.relays.length === 0) && qInfo.relay) { token.relays = [qInfo.relay]; } if (!token.author && qInfo.author) { token.author = qInfo.author; } } } } } // 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-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. // Merge the event's own emoji tags with the viewer's custom emoji collection // so shortcodes render even when the published event omitted the tag. const { emojis: viewerEmojis } = useCustomEmojis(); const emojiMap = useMemo(() => { const map = buildEmojiMap(event.tags); // Viewer's collection is a fallback β€” event tags take priority for (const e of viewerEmojis) { if (!map.has(e.shortcode)) { map.set(e.shortcode, e.url); } } return map; }, [event.tags, viewerEmojis]); // Parse imeta tags for dim/blurhash to pass to ImageGallery const imetaMap = useMemo(() => { const map = new Map(); for (const tag of event.tags) { if (tag[0] !== 'imeta') continue; const parts: Record = {}; for (let i = 1; i < tag.length; i++) { const p = tag[i]; const sp = p.indexOf(' '); if (sp !== -1) parts[p.slice(0, sp)] = p.slice(sp + 1); } if (parts.url) map.set(parts.url, { dim: parts.dim, blurhash: parts.blurhash }); } return map; }, [event.tags]); // Group consecutive image-embed tokens (β‰₯2) into image-gallery tokens const groupedTokens = useMemo(() => { const result: ContentToken[] = []; let i = 0; while (i < tokens.length) { const token = tokens[i]; if (token.type === 'image-embed') { // Collect this and any immediately following image-embed tokens const run: string[] = [token.url]; let j = i + 1; while (j < tokens.length && tokens[j].type === 'image-embed') { run.push((tokens[j] as { type: 'image-embed'; url: string }).url); j++; } if (run.length >= 2) { result.push({ type: 'image-gallery', urls: run }); } else { result.push(token); } i = j; } else { result.push(token); i++; } } return result; }, [tokens]); // Collect all inline image URLs (in order) for the shared lightbox const allImages = useMemo( () => groupedTokens.flatMap((t) => { if (t.type === 'image-embed') return [t.url]; if (t.type === 'image-gallery') return t.urls; return []; }), [groupedTokens], ); // 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 β€” unicode and/or NIP-30 custom emojis (single text token) const isEmojiOnly = groupedTokens.length === 1 && groupedTokens[0].type === 'text' && isOnlyEmojisOrCustom(groupedTokens[0].value, emojiMap); // Build a map from grouped token index β†’ starting image list index for lightbox positioning const tokenImageIndex = useMemo(() => { const map = new Map(); let imgCount = 0; groupedTokens.forEach((t, i) => { if (t.type === 'image-embed') { map.set(i, imgCount++); } else if (t.type === 'image-gallery') { map.set(i, imgCount); imgCount += t.urls.length; } }); return map; }, [groupedTokens]); return (
{groupedTokens.map((token, i) => { switch (token.type) { case 'text': return {linkifyFlags(emojify(token.value, emojiMap, isEmojiOnly ? 'inline h-12 w-12 object-contain align-text-bottom' : undefined))}; 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 'image-gallery': { if (disableEmbeds) { return {token.urls.map(() => '\n').join('')}; } const galleryStartIndex = tokenImageIndex.get(i) ?? 0; const galleryLightboxIndex = lightboxIndex !== null && lightboxIndex >= galleryStartIndex && lightboxIndex < galleryStartIndex + token.urls.length ? lightboxIndex - galleryStartIndex : null; return ( { setLightboxIndex(galleryStartIndex + idx); }} onLightboxClose={closeLightbox} /> ); } case 'link-embed': if (disableEmbeds) { return ( e.stopPropagation()} > {token.url} ); } return ; case 'inline-link': return ( e.stopPropagation()} > {token.url} ); 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} ); case 'relay-link': return ( e.stopPropagation()} > {token.url} ); } })} {/* Shared lightbox for standalone inline images (not gallery groups) */} {lightboxIndex !== null && !groupedTokens.some( (t, i) => t.type === 'image-gallery' && lightboxIndex >= (tokenImageIndex.get(i) ?? 0) && lightboxIndex < (tokenImageIndex.get(i) ?? 0) + t.urls.length, ) && ( )}
); } /** Inline image thumbnail that opens the shared lightbox on click. * Uses a min-height placeholder to prevent layout shifts while loading. */ function InlineImage({ url, onClick }: { url: string; onClick: (e: React.MouseEvent) => void }) { const { src, onError } = useBlossomFallback(url); const [loaded, setLoaded] = useState(false); 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} ); }