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"; import { parseContent } from "../../lib/parsing"; function ImageGrid({ images, onImageClick }: { images: string[]; onImageClick: (index: number) => void }) { const count = images.length; if (count === 0) return null; const maxVisible = Math.min(count, 4); const extraCount = count - 4; const visible = images.slice(0, maxVisible); if (count === 1) { return (
{ e.stopPropagation(); onImageClick(0); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
); } if (count === 2) { return (
{visible.map((src, idx) => ( { e.stopPropagation(); onImageClick(idx); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> ))}
); } if (count === 3) { return (
{ e.stopPropagation(); onImageClick(0); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> { e.stopPropagation(); onImageClick(1); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> { e.stopPropagation(); onImageClick(2); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
); } // 4+ images: 2x2 grid with "+N more" overlay on 4th return (
{visible.map((src, idx) => (
{ e.stopPropagation(); onImageClick(idx); }} onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} /> {idx === 3 && extraCount > 0 && (
{ e.stopPropagation(); onImageClick(idx); }} > +{extraCount}
)}
))}
); } // 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}

); } function MentionName({ pubkey, fallback }: { pubkey?: string; fallback: string }) { const profile = useProfile(pubkey ?? ""); if (!pubkey) return <>{fallback}; const name = profile?.displayName || profile?.name; return <>{name || fallback}; } 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 { openHashtag } = 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); }} > @ ); break; case "hashtag": inlineElements.push( { e.stopPropagation(); openHashtag(seg.value); }} > {seg.display} ); break; default: break; } }); return (
{inlineElements}
{/* Images stay inside the clickable area (they have their own stopPropagation) */} {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(); openHashtag(seg.value); }} > {seg.display} ); break; default: break; } }); return (
{inlineElements}
{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) => ( ))}
); }