import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; import { ExternalLink, GitFork, Package, Play } from 'lucide-react'; import { useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { ExternalFavicon } from '@/components/ExternalFavicon'; import { NsitePreviewDialog } from '@/components/NsitePreviewDialog'; import { Skeleton } from '@/components/ui/skeleton'; import { useAddrEvent } from '@/hooks/useEvent'; import { NostrURI } from '@/lib/NostrURI'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; /** Get a tag value by name. */ function getTag(tags: string[][], name: string): string | undefined { return tags.find(([n]) => n === name)?.[1]; } /** Get all values for a tag name. */ function getAllTags(tags: string[][], name: string): string[] { return tags.filter(([n]) => n === name).map(([, v]) => v); } /** Parse kind-0-style metadata from the content field. */ function parseHandlerMetadata(content: string): NostrMetadata { if (!content) return {}; try { return JSON.parse(content) as NostrMetadata; } catch { return {}; } } /** Get the website URL from web handler tags or metadata. */ function getWebsiteUrl(tags: string[][], metadata: NostrMetadata): string | undefined { const webTags = tags.filter(([n]) => n === 'web'); for (const tag of webTags) { const url = tag[1]; if (url) { const base = url.replace(//g, '').replace(/\/+$/, ''); return base; } } return metadata.website; } /** Extract the display domain from a URL. */ function displayDomain(url: string): string { try { return new URL(url).hostname.replace(/^www\./, ''); } catch { return url; } } /** Build a Shakespeare "Edit with Shakespeare" URL from a kind 30617 `a` tag, if present. */ function getShakespeareUrl(tags: string[][]): string | undefined { for (const tag of tags) { if (tag[0] !== 'a') continue; const parts = tag[1]?.split(':'); if (!parts || parts[0] !== '30617' || parts.length < 3) continue; const pubkey = parts[1]; const identifier = parts.slice(2).join(':'); const nostrUri = new NostrURI({ pubkey, identifier }).toString(); return `https://shakespeare.diy/clone?url=${encodeURIComponent(nostrUri)}`; } return undefined; } interface NsiteRef { /** The author pubkey (hex) of the kind 35128 event. */ pubkey: string; /** The d-tag identifier of the kind 35128 event. */ identifier: string; } /** * Extract nsite info from a kind 35128 `a` tag, if present. * The `a` tag value format is `"35128::"`. */ function getNsiteRef(tags: string[][]): NsiteRef | undefined { for (const tag of tags) { if (tag[0] !== 'a') continue; const parts = tag[1]?.split(':'); if (!parts || parts[0] !== '35128' || parts.length < 3) continue; const pubkey = parts[1]; const identifier = parts.slice(2).join(':'); if (!pubkey || !identifier) continue; return { pubkey, identifier }; } return undefined; } interface AppHandlerContentProps { event: NostrEvent; /** If true, show compact preview (used in NoteCard feed). */ compact?: boolean; } /** Renders a kind 31990 NIP-89 application handler event as a showcase-style card. */ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { const metadata = useMemo(() => parseHandlerMetadata(event.content), [event.content]); const name = metadata.name || getTag(event.tags, 'name') || getTag(event.tags, 'd') || 'Unknown App'; const about = metadata.about; // Sanitize image URLs to reject non-https schemes (http IP leaks, data: URIs, // etc.). The CSP \`img-src\` already blocks most of these, but sanitizing // defense-in-depth matches the treatment of the website URL below and keeps // the component safe if it is ever rendered outside the app's own CSP. const picture = sanitizeUrl(metadata.picture); const banner = sanitizeUrl(metadata.banner); const websiteUrl = sanitizeUrl(getWebsiteUrl(event.tags, metadata)); const hashtags = getAllTags(event.tags, 't'); const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]); const nsiteRef = useMemo(() => getNsiteRef(event.tags), [event.tags]); const [previewOpen, setPreviewOpen] = useState(false); // Fetch the actual nsite event so we can serve files directly from Blossom. const { data: nsiteEvent } = useAddrEvent( nsiteRef ? { kind: 35128, pubkey: nsiteRef.pubkey, identifier: nsiteRef.identifier } : undefined, ); if (compact) { return ( <>
{/* Banner hero */} {banner && (
)} {/* Content */}
{/* App icon — overlaps the banner hero like a profile avatar */}
{picture ? ( {name} { (e.currentTarget as HTMLElement).style.display = 'none'; }} /> ) : (
)}
{/* Name + domain */}

{name}

{websiteUrl && (
{displayDomain(websiteUrl)}
)}
{/* Description */} {about && (

{about}

)} {/* Tags */} {hashtags.length > 0 && (
{hashtags.slice(0, 4).map((tag) => ( e.stopPropagation()} > #{tag} ))}
)} {/* Actions */}
{nsiteRef && ( )} {websiteUrl && ( )} {shakespeareUrl && ( )}
{nsiteRef && nsiteEvent && ( )} ); } // Full detail view return (
{/* Banner hero */} {banner && (
)} {/* Content */}
{/* App icon — overlaps the banner hero like a profile avatar */}
{picture ? ( {name} { (e.currentTarget as HTMLElement).style.display = 'none'; }} /> ) : (
)}
{/* Name + domain */}

{name}

{websiteUrl && (
{displayDomain(websiteUrl)}
)}
{/* Description */} {about && (

{about}

)} {/* Tags */} {hashtags.length > 0 && (
{hashtags.map((tag) => ( e.stopPropagation()} > #{tag} ))}
)} {/* Actions */}
{nsiteRef && ( )} {websiteUrl && ( )} {shakespeareUrl && ( )}
{nsiteRef && nsiteEvent && ( )}
); } /** Skeleton loading state for AppHandlerContent. */ export function AppHandlerSkeleton() { return (
); }