Add Run button and nsite preview dialog to app handler cards
When a kind 31990 app event includes an 'a' tag pointing to a kind 35128 nsite, display a 'Run' button that opens an in-app preview dialog. The dialog embeds the nsite in a sandboxed iframe via the Shakespeare iframe-fetch-client protocol (local-shakespeare.dev), proxying fetch requests from the iframe to the live nsite URL so the SPA renders without needing CORS headers on the origin server.
This commit is contained in:
@@ -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:<pubkey>:<d-tag>"`.
|
||||
* 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 (
|
||||
<>
|
||||
<div className="mt-2">
|
||||
<div className="rounded-xl border border-border overflow-hidden transition-all duration-300 hover:shadow-md hover:border-primary/20">
|
||||
{/* Banner hero */}
|
||||
@@ -155,8 +187,18 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{nsitePreviewUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
>
|
||||
<Play className="size-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm" className="h-7 text-xs">
|
||||
<Button asChild size="sm" variant={nsitePreviewUrl ? 'secondary' : 'default'} className="h-7 text-xs">
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
@@ -175,6 +217,16 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nsitePreviewUrl && (
|
||||
<NsitePreviewDialog
|
||||
nsiteUrl={nsitePreviewUrl}
|
||||
appName={name}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -252,8 +304,17 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{nsitePreviewUrl && (
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={(e) => { e.stopPropagation(); setPreviewOpen(true); }}
|
||||
>
|
||||
<Play className="size-3.5 mr-1.5" />
|
||||
Run
|
||||
</Button>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<Button asChild size="sm">
|
||||
<Button asChild size="sm" variant={nsitePreviewUrl ? 'secondary' : 'default'}>
|
||||
<a href={websiteUrl} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
Open App
|
||||
<ExternalLink className="size-3 ml-1.5" />
|
||||
@@ -271,6 +332,15 @@ export function AppHandlerContent({ event, compact }: AppHandlerContentProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{nsitePreviewUrl && (
|
||||
<NsitePreviewDialog
|
||||
nsiteUrl={nsitePreviewUrl}
|
||||
appName={name}
|
||||
open={previewOpen}
|
||||
onOpenChange={setPreviewOpen}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
body: string | null;
|
||||
};
|
||||
};
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface JSONRPCResponse {
|
||||
jsonrpc: '2.0';
|
||||
result?: {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: Record<string, string>;
|
||||
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<string>(makeSessionId());
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [currentPath, setCurrentPath] = useState('/');
|
||||
const [history, setHistory] = useState<string[]>(['/']);
|
||||
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<string, string> = {};
|
||||
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<string, unknown>) => {
|
||||
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 (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className={
|
||||
isFullscreen
|
||||
? 'fixed inset-0 max-w-none w-screen h-screen rounded-none p-0 flex flex-col gap-0 [&>button]:hidden'
|
||||
: 'max-w-4xl w-full h-[80vh] p-0 flex flex-col gap-0'
|
||||
}
|
||||
>
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{appName} — Preview</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* Browser chrome toolbar */}
|
||||
<div className="h-11 flex items-center gap-1.5 px-2 border-b bg-muted/30 shrink-0 rounded-t-lg">
|
||||
{/* Back / Forward / Refresh */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleBack}
|
||||
disabled={!canGoBack}
|
||||
title="Back"
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleForward}
|
||||
disabled={!canGoForward}
|
||||
title="Forward"
|
||||
>
|
||||
<ArrowRight className="size-3.5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleRefresh}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Address bar */}
|
||||
<div className="flex-1 mx-1">
|
||||
<div className="h-7 bg-background border rounded-md flex items-center px-2.5 text-xs text-muted-foreground font-mono truncate select-none">
|
||||
{displayUrl}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Open in new tab */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => window.open(nsiteUrl, '_blank', 'noopener,noreferrer')}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
</Button>
|
||||
|
||||
{/* Fullscreen toggle */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => setIsFullscreen((f) => !f)}
|
||||
title={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
|
||||
>
|
||||
{isFullscreen ? <Minimize2 className="size-3.5" /> : <Maximize2 className="size-3.5" />}
|
||||
</Button>
|
||||
|
||||
{/* Close */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* iframe */}
|
||||
<div className="flex-1 min-h-0 bg-background">
|
||||
<iframe
|
||||
key={`${sessionIdRef.current}-${open}`}
|
||||
ref={iframeRef}
|
||||
src={iframeSrc}
|
||||
className="w-full h-full border-0"
|
||||
title={`${appName} preview`}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms"
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user