import { ReactNode, useEffect, useState } from "react"; import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk"; import { useUIStore } from "../../stores/ui"; import { fetchNoteById } from "../../lib/nostr"; import { useProfile } from "../../hooks/useProfile"; import { shortenPubkey } from "../../lib/utils"; import { ImageLightbox } from "../shared/ImageLightbox"; // Regex patterns const URL_REGEX = /https?:\/\/[^\s<>"')\]]+/g; const IMAGE_EXTENSIONS = /\.(jpg|jpeg|png|gif|webp|svg)(\?[^\s]*)?$/i; const VIDEO_EXTENSIONS = /\.(mp4|webm|mov|ogg|m4v|avi)(\?[^\s]*)?$/i; const AUDIO_EXTENSIONS = /\.(mp3|wav|flac|aac|m4a|opus|ogg)(\?[^\s]*)?$/i; const YOUTUBE_REGEX = /(?:youtube\.com\/(?:watch\?v=|embed\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/; const TIDAL_REGEX = /tidal\.com\/(?:browse\/)?(?:track|album|playlist)\/([a-zA-Z0-9-]+)/; const SPOTIFY_REGEX = /open\.spotify\.com\/(track|album|playlist|episode|show)\/([a-zA-Z0-9]+)/; const VIMEO_REGEX = /vimeo\.com\/(\d+)/; const NOSTR_MENTION_REGEX = /nostr:(npub1[a-z0-9]+|note1[a-z0-9]+|nevent1[a-z0-9]+|nprofile1[a-z0-9]+|naddr1[a-z0-9]+)/g; const HASHTAG_REGEX = /(?<=\s|^)#(\w{2,})/g; interface ContentSegment { type: "text" | "link" | "image" | "video" | "audio" | "youtube" | "vimeo" | "spotify" | "tidal" | "mention" | "hashtag" | "quote"; value: string; // for "quote": the hex event ID display?: string; mediaId?: string; // video/embed ID for youtube/vimeo mediaType?: string; // e.g. "track", "album" for spotify/tidal } function parseContent(content: string): ContentSegment[] { const segments: ContentSegment[] = []; const allMatches: { index: number; length: number; segment: ContentSegment }[] = []; // Find URLs let match: RegExpExecArray | null; const urlRegex = new RegExp(URL_REGEX.source, "g"); while ((match = urlRegex.exec(content)) !== null) { const url = match[0]; // Clean trailing punctuation that's likely not part of the URL const cleaned = url.replace(/[.,;:!?)]+$/, ""); if (IMAGE_EXTENSIONS.test(cleaned)) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "image", value: cleaned }, }); } else if (VIDEO_EXTENSIONS.test(cleaned)) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "video", value: cleaned }, }); } else if (AUDIO_EXTENSIONS.test(cleaned)) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "audio", value: cleaned }, }); } else { // Check for embeddable media URLs const ytMatch = cleaned.match(YOUTUBE_REGEX); const vimeoMatch = cleaned.match(VIMEO_REGEX); const spotifyMatch = cleaned.match(SPOTIFY_REGEX); const tidalMatch = cleaned.match(TIDAL_REGEX); if (ytMatch) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "youtube", value: cleaned, mediaId: ytMatch[1] }, }); } else if (vimeoMatch) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "vimeo", value: cleaned, mediaId: vimeoMatch[1] }, }); } else if (spotifyMatch) { allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "spotify", value: cleaned, mediaType: spotifyMatch[1], mediaId: spotifyMatch[2] }, }); } else if (tidalMatch) { // Extract the type (track/album/playlist) from the URL const tidalTypeMatch = cleaned.match(/tidal\.com\/(?:browse\/)?(track|album|playlist)\//); allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "tidal", value: cleaned, mediaType: tidalTypeMatch?.[1] ?? "track", mediaId: tidalMatch[1] }, }); } else { // Shorten display URL let display = cleaned; try { const u = new URL(cleaned); display = u.hostname + (u.pathname !== "/" ? u.pathname : ""); if (display.length > 50) display = display.slice(0, 47) + "…"; } catch { /* keep as-is */ } allMatches.push({ index: match.index, length: cleaned.length, segment: { type: "link", value: cleaned, display }, }); } } } // Find nostr: mentions const mentionRegex = new RegExp(NOSTR_MENTION_REGEX.source, "g"); while ((match = mentionRegex.exec(content)) !== null) { const raw = match[1]; let display = raw.slice(0, 12) + "…"; let isQuote = false; let eventId = ""; try { const decoded = nip19.decode(raw); if (decoded.type === "npub") { display = raw.slice(0, 12) + "…"; } else if (decoded.type === "note") { // Always treat note1 references as inline quotes isQuote = true; eventId = decoded.data as string; } else if (decoded.type === "nevent") { const d = decoded.data as { id: string; kind?: number }; // Only quote kind-1 notes (or unknown kind) if (!d.kind || d.kind === 1) { isQuote = true; eventId = d.id; } else { display = "event:" + raw.slice(7, 15) + "…"; } } } catch { /* keep default */ } allMatches.push({ index: match.index, length: match[0].length, segment: isQuote ? { type: "quote", value: eventId } : { type: "mention", value: raw, display }, }); } // Find hashtags const hashtagRegex = new RegExp(HASHTAG_REGEX.source, "g"); while ((match = hashtagRegex.exec(content)) !== null) { allMatches.push({ index: match.index, length: match[0].length, segment: { type: "hashtag", value: match[1], display: `#${match[1]}` }, }); } // Sort matches by index, remove overlaps allMatches.sort((a, b) => a.index - b.index); const filtered: typeof allMatches = []; let lastEnd = 0; for (const m of allMatches) { if (m.index >= lastEnd) { filtered.push(m); lastEnd = m.index + m.length; } } // Build segments let cursor = 0; for (const m of filtered) { if (m.index > cursor) { segments.push({ type: "text", value: content.slice(cursor, m.index) }); } segments.push(m.segment); cursor = m.index + m.length; } if (cursor < content.length) { segments.push({ type: "text", value: content.slice(cursor) }); } return segments; } // Returns true if we handled the URL internally (njump.me interception). function tryHandleUrlInternally(url: string): boolean { try { const u = new URL(url); if (u.hostname === "njump.me") { const entity = u.pathname.replace(/^\//, ""); if (entity) return tryOpenNostrEntity(entity); } } catch { /* not a valid URL */ } return false; } // Decodes a NIP-19 bech32 string and navigates internally where possible. // Returns true if handled, false if the caller should fall back to a browser open. function tryOpenNostrEntity(raw: string): boolean { try { const decoded = nip19.decode(raw); const { openProfile, openArticle } = useUIStore.getState(); if (decoded.type === "npub") { openProfile(decoded.data as string); return true; } if (decoded.type === "nprofile") { openProfile((decoded.data as { pubkey: string }).pubkey); return true; } if (decoded.type === "naddr") { const { kind } = decoded.data as { kind: number; pubkey: string; identifier: string }; if (kind === 30023) { openArticle(raw); return true; } } // note / nevent / other naddr kinds — fall through to njump.me } catch { /* invalid entity */ } return false; } function QuotePreview({ eventId }: { eventId: string }) { const [event, setEvent] = useState(null); const { openThread, currentView } = useUIStore(); const profile = useProfile(event?.pubkey ?? ""); useEffect(() => { if (!eventId) return; fetchNoteById(eventId).then(setEvent); }, [eventId]); if (!event) return null; const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey); const preview = event.content.slice(0, 160) + (event.content.length > 160 ? "…" : ""); return (
{ e.stopPropagation(); openThread(event, currentView as "feed" | "profile"); }} >
{profile?.picture && ( { (e.target as HTMLImageElement).style.display = "none"; }} /> )} {name}

{preview}

); } interface NoteContentProps { content: string; /** Render only inline text (no media blocks). Used inside the clickable area. */ inline?: boolean; /** Render only media blocks (videos, embeds, quotes). Used outside the clickable area. */ mediaOnly?: boolean; } export function NoteContent({ content, inline, mediaOnly }: NoteContentProps) { const { openSearch } = useUIStore(); const segments = parseContent(content); const images: string[] = segments.filter((s) => s.type === "image").map((s) => s.value); const videos: string[] = segments.filter((s) => s.type === "video").map((s) => s.value); const audios: string[] = segments.filter((s) => s.type === "audio").map((s) => s.value); const youtubes = segments.filter((s) => s.type === "youtube"); const vimeos = segments.filter((s) => s.type === "vimeo"); const spotifys = segments.filter((s) => s.type === "spotify"); const tidals = segments.filter((s) => s.type === "tidal"); const quoteIds: string[] = segments.filter((s) => s.type === "quote").map((s) => s.value); const [lightboxIndex, setLightboxIndex] = useState(null); // --- Inline text + images (safe inside clickable wrapper) --- if (inline) { const inlineElements: ReactNode[] = []; segments.forEach((seg, i) => { switch (seg.type) { case "text": inlineElements.push({seg.value}); break; case "link": inlineElements.push( { if (tryHandleUrlInternally(seg.value)) e.preventDefault(); }} > {seg.display} ); break; case "mention": inlineElements.push( { e.stopPropagation(); tryOpenNostrEntity(seg.value); }} > @{seg.display} ); break; case "hashtag": inlineElements.push( { e.stopPropagation(); openSearch(`#${seg.value}`); }} > {seg.display} ); break; default: break; } }); return (
{inlineElements}
{/* Images stay inside the clickable area (they have their own stopPropagation) */} {images.length > 0 && (
1 ? "grid grid-cols-2 gap-1" : ""}`}> {images.map((src, idx) => ( { e.stopPropagation(); setLightboxIndex(idx); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> ))}
)} {lightboxIndex !== null && ( setLightboxIndex(null)} onNavigate={setLightboxIndex} /> )}
); } // --- Media blocks only (rendered OUTSIDE the clickable wrapper) --- if (mediaOnly) { const hasMedia = videos.length > 0 || audios.length > 0 || youtubes.length > 0 || vimeos.length > 0 || spotifys.length > 0 || tidals.length > 0 || quoteIds.length > 0; if (!hasMedia) return null; return (
e.stopPropagation()}> {/* Videos */} {videos.length > 0 && (
{videos.map((src, i) => (
)} {/* Audio */} {audios.length > 0 && (
{audios.map((src, i) => { const filename = src.split("/").pop()?.split("?")[0] ?? src; return (
{filename}
); })}
)} {/* YouTube — open in browser (WebKitGTK can't play YouTube iframes) */} {youtubes.map((seg, i) => (
YouTube
{seg.value}
))} {/* Vimeo — open in browser */} {vimeos.map((seg, i) => (
Vimeo
{seg.value}
))} {/* Spotify — open in browser/app */} {spotifys.map((seg, i) => (
S
Spotify · {seg.mediaType}
{seg.value}
))} {/* Tidal — open in browser/app */} {tidals.map((seg, i) => (
T
Tidal · {seg.mediaType}
{seg.value}
))} {/* Quoted notes */} {quoteIds.map((id) => ( ))}
); } // --- Default: full render (used in ThreadView, SearchView, etc.) --- const inlineElements: ReactNode[] = []; segments.forEach((seg, i) => { switch (seg.type) { case "text": inlineElements.push({seg.value}); break; case "link": inlineElements.push( { if (tryHandleUrlInternally(seg.value)) e.preventDefault(); }} > {seg.display} ); break; case "mention": inlineElements.push( { e.stopPropagation(); tryOpenNostrEntity(seg.value); }} > @{seg.display} ); break; case "hashtag": inlineElements.push( { e.stopPropagation(); openSearch(`#${seg.value}`); }} > {seg.display} ); break; default: break; } }); return (
{inlineElements}
{images.length > 0 && (
1 ? "grid grid-cols-2 gap-1" : ""}`}> {images.map((src, idx) => ( { e.stopPropagation(); setLightboxIndex(idx); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> ))}
)} {lightboxIndex !== null && ( setLightboxIndex(null)} onNavigate={setLightboxIndex} /> )} {videos.length > 0 && (
{videos.map((src, i) => (
)} {audios.length > 0 && (
{audios.map((src, i) => { const filename = src.split("/").pop()?.split("?")[0] ?? src; return (
{filename}
); })}
)} {youtubes.map((seg, i) => (
YouTube
{seg.value}
))} {vimeos.map((seg, i) => (
Vimeo
{seg.value}
))} {spotifys.map((seg, i) => (
S
Spotify · {seg.mediaType}
{seg.value}
))} {tidals.map((seg, i) => (
T
Tidal · {seg.mediaType}
{seg.value}
))} {quoteIds.map((id) => ( ))}
); }