diff --git a/src/components/AppHandlerContent.tsx b/src/components/AppHandlerContent.tsx index adaa0ca5..4d795ca1 100644 --- a/src/components/AppHandlerContent.tsx +++ b/src/components/AppHandlerContent.tsx @@ -1,10 +1,11 @@ import type { NostrEvent, NostrMetadata } from '@nostrify/nostrify'; -import { ExternalLink, GitFork, Package } from 'lucide-react'; -import { useMemo } from 'react'; +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 { NostrURI } from '@/lib/NostrURI'; import { cn } from '@/lib/utils'; @@ -65,6 +66,34 @@ function getShakespeareUrl(tags: string[][]): string | undefined { return undefined; } +/** Encode a 32-byte hex pubkey as a base36 string (50 chars, zero-padded). */ +function hexToBase36(hex: string): string { + let n = 0n; + for (let i = 0; i < hex.length; i++) { + n = n * 16n + BigInt(parseInt(hex[i], 16)); + } + return n.toString(36).padStart(50, '0'); +} + +/** + * Get the nsite preview URL from a kind 35128 `a` tag, if present. + * The `a` tag value format is `"35128::"`. + * Returns the nsite.lol gateway URL for the referenced nsite. + */ +function getNsitePreviewUrl(tags: string[][]): string | 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 dTag = parts.slice(2).join(':'); + if (!pubkey || !dTag) continue; + const pubkeyB36 = hexToBase36(pubkey); + return `https://${pubkeyB36}${dTag}.nsite.lol`; + } + return undefined; +} + interface AppHandlerContentProps { event: NostrEvent; /** If true, show compact preview (used in NoteCard feed). */ @@ -83,9 +112,12 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { const hashtags = getAllTags(event.tags, 't'); const shakespeareUrl = useMemo(() => getShakespeareUrl(event.tags), [event.tags]); + const nsitePreviewUrl = useMemo(() => getNsitePreviewUrl(event.tags), [event.tags]); + const [previewOpen, setPreviewOpen] = useState(false); if (compact) { return ( + <>
{/* Banner hero */} @@ -155,8 +187,18 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { {/* Actions */}
+ {nsitePreviewUrl && ( + + )} {websiteUrl && ( -
+ + {nsitePreviewUrl && ( + + )} + ); } @@ -252,8 +304,17 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) { {/* Actions */}
+ {nsitePreviewUrl && ( + + )} {websiteUrl && ( -
+ + {nsitePreviewUrl && ( + + )} ); } diff --git a/src/components/NsitePreviewDialog.tsx b/src/components/NsitePreviewDialog.tsx new file mode 100644 index 00000000..f46b2e16 --- /dev/null +++ b/src/components/NsitePreviewDialog.tsx @@ -0,0 +1,331 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ArrowLeft, ArrowRight, ExternalLink, Maximize2, Minimize2, RefreshCw, X } from 'lucide-react'; +import { VisuallyHidden } from '@radix-ui/react-visually-hidden'; + +import { Button } from '@/components/ui/button'; +import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog'; + +/** The wildcard-to-localhost preview domain used by Shakespeare's iframe-fetch-client. */ +const PREVIEW_DOMAIN = 'local-shakespeare.dev'; + +/** A stable session ID for the iframe origin (one per component mount). */ +function makeSessionId(): string { + return Math.random().toString(36).slice(2, 10); +} + +interface JSONRPCFetchRequest { + jsonrpc: '2.0'; + method: 'fetch'; + params: { + request: { + url: string; + method: string; + headers: Record; + body: string | null; + }; + }; + id: number; +} + +interface JSONRPCResponse { + jsonrpc: '2.0'; + result?: { + status: number; + statusText: string; + headers: Record; + body: string | null; + }; + error?: { + code: number; + message: string; + }; + id: number; +} + +interface NsitePreviewDialogProps { + /** The nsite URL to preview (e.g. https://abc.nsite.lol). */ + nsiteUrl: string; + /** Display name for the app. */ + appName: string; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * An in-app preview dialog that loads an nsite in a sandboxed iframe, + * using the Shakespeare iframe-fetch-client protocol over local-shakespeare.dev. + * + * The parent window intercepts JSON-RPC `fetch` requests from the iframe and + * proxies them to the live nsite URL, so the SPA can run without needing CORS + * headers on the origin server. + */ +export function NsitePreviewDialog({ nsiteUrl, appName, open, onOpenChange }: NsitePreviewDialogProps) { + const sessionIdRef = useRef(makeSessionId()); + const iframeRef = useRef(null); + const [currentPath, setCurrentPath] = useState('/'); + const [history, setHistory] = useState(['/']); + const [historyIndex, setHistoryIndex] = useState(0); + const [isFullscreen, setIsFullscreen] = useState(false); + + // Derive a stable iframe origin from the session id and preview domain + const iframeOrigin = `https://${sessionIdRef.current}.${PREVIEW_DOMAIN}`; + const iframeSrc = `${iframeOrigin}/`; + + /** Send a JSON-RPC response back to the iframe. */ + const sendResponse = useCallback((message: JSONRPCResponse) => { + iframeRef.current?.contentWindow?.postMessage(message, iframeOrigin); + }, [iframeOrigin]); + + /** Handle a fetch request from the iframe by proxying it to the nsite. */ + const handleFetch = useCallback(async (request: JSONRPCFetchRequest) => { + const { params, id } = request; + const { request: fetchRequest } = params; + + try { + const requestedUrl = new URL(fetchRequest.url); + + // Only serve requests for our iframe origin + if (requestedUrl.origin !== iframeOrigin) { + sendResponse({ + jsonrpc: '2.0', + error: { code: -32003, message: 'Origin mismatch' }, + id, + }); + return; + } + + // Build the proxied URL: replace the iframe origin with the nsite origin + const nsiteBase = new URL(nsiteUrl); + const proxyUrl = `${nsiteBase.origin}${requestedUrl.pathname}${requestedUrl.search}`; + + const res = await fetch(proxyUrl, { + method: fetchRequest.method, + headers: fetchRequest.headers, + body: fetchRequest.body ?? undefined, + // Don't follow redirects automatically so we can handle them + redirect: 'follow', + }); + + // Read as ArrayBuffer → base64 so binary assets work correctly + const buffer = await res.arrayBuffer(); + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + const bodyBase64 = btoa(binary); + + const responseHeaders: Record = {}; + res.headers.forEach((value, key) => { + responseHeaders[key] = value; + }); + + sendResponse({ + jsonrpc: '2.0', + result: { + status: res.status, + statusText: res.statusText, + headers: responseHeaders, + body: bodyBase64, + }, + id, + }); + } catch (err) { + sendResponse({ + jsonrpc: '2.0', + error: { code: -32002, message: String(err) }, + id, + }); + } + }, [iframeOrigin, nsiteUrl, sendResponse]); + + /** Handle navigation state updates from the iframe. */ + const handleNavigationState = useCallback((params: { + currentUrl: string; + canGoBack: boolean; + canGoForward: boolean; + }) => { + const path = params.currentUrl; + setCurrentPath(path); + setHistory((prev) => { + if (path !== prev[historyIndex]) { + const next = [...prev.slice(0, historyIndex + 1), path]; + setHistoryIndex(next.length - 1); + return next; + } + return prev; + }); + }, [historyIndex]); + + // Listen for messages from the iframe + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (event.origin !== iframeOrigin) return; + const message = event.data; + if (message?.jsonrpc !== '2.0') return; + if (message.method === 'fetch') { + handleFetch(message as JSONRPCFetchRequest); + } else if (message.method === 'updateNavigationState') { + handleNavigationState(message.params); + } + }; + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [iframeOrigin, handleFetch, handleNavigationState]); + + const sendNavCommand = useCallback((method: string, params?: Record) => { + iframeRef.current?.contentWindow?.postMessage( + { jsonrpc: '2.0', method, params: params ?? {}, id: Date.now() }, + iframeOrigin, + ); + }, [iframeOrigin]); + + const handleBack = () => { + if (historyIndex > 0) { + const newIndex = historyIndex - 1; + setHistoryIndex(newIndex); + setCurrentPath(history[newIndex]); + sendNavCommand('navigate', { url: history[newIndex] }); + } + }; + + const handleForward = () => { + if (historyIndex < history.length - 1) { + const newIndex = historyIndex + 1; + setHistoryIndex(newIndex); + setCurrentPath(history[newIndex]); + sendNavCommand('navigate', { url: history[newIndex] }); + } + }; + + const handleRefresh = () => { + sendNavCommand('refresh'); + }; + + // Reset state when dialog opens/closes + useEffect(() => { + if (open) { + setCurrentPath('/'); + setHistory(['/']); + setHistoryIndex(0); + setIsFullscreen(false); + // Generate a fresh session id each time the dialog opens + sessionIdRef.current = makeSessionId(); + } + }, [open]); + + const canGoBack = historyIndex > 0; + const canGoForward = historyIndex < history.length - 1; + + // Derive the display URL shown in the address bar + const displayUrl = (() => { + try { + const base = new URL(nsiteUrl); + return `${base.hostname}${currentPath === '/' ? '' : currentPath}`; + } catch { + return nsiteUrl; + } + })(); + + return ( + + button]:hidden' + : 'max-w-4xl w-full h-[80vh] p-0 flex flex-col gap-0' + } + > + + {appName} — Preview + + + {/* Browser chrome toolbar */} +
+ {/* Back / Forward / Refresh */} + + + + + {/* Address bar */} +
+
+ {displayUrl} +
+
+ + {/* Open in new tab */} + + + {/* Fullscreen toggle */} + + + {/* Close */} + +
+ + {/* iframe */} +
+