55d873548d
The iframe.diy-based sandbox infrastructure powered two features: the nsite preview dialog (Run button on NsiteCard and AppHandlerContent) and the webxdc embed (cartridge launcher + sandboxed iframe runtime with kind 4932/20932 sync events). Both are removed wholesale: - iframe sandbox plumbing: SandboxFrame, src/lib/sandbox/*, iframeSubdomain, previewInjectedScript. - nsite preview: NsitePreviewDialog, NsitePermissionManager, NsitePermissionPrompt, useNsiteSignerRpc, nsitePermissions, nsiteNostrProvider, NsitePlayerContext. - webxdc runtime: WebxdcEmbed, Webxdc, GameControls, useWebxdc, webxdcMeta, public/cartridge.png, NOSTR_WEBXDC.md, @webxdc/types dependency, kindLabels/signerWithNudge entries for kinds 4932/20932. - AppConfig: drop sandboxDomain, showWebxdc, feedIncludeWebxdc. - extraKinds: drop kind 1063 webxdc entry; sidebar drops 'webxdc'. - ComposeBox: .xdc uploads now flow through the generic file path (no UUID injection, no manifest extraction, .xdc removed from the file picker accept list). - NoteContent / FileMetadataContent: webxdc branches removed; .xdc attachments fall through to the generic file card. - LayoutContext / CenterColumnContext: only consumed by the removed fullscreen preview panels — deleted along with its provider in AppRouter. NsiteCard keeps its rich link-preview card but loses the Run button and preview dialog. AppHandlerContent keeps a kind 35128 `a`-tag reference but replaces 'Run' with an external 'Open Site' link to `<pubkeyB36><dTag>.nsite.lol`. The standard HTML iframe `sandbox` attribute used by SpotifyEmbed/TweetEmbed/RedditEmbed is unrelated to iframe.diy and stays.
157 lines
5.7 KiB
TypeScript
157 lines
5.7 KiB
TypeScript
import { Download, FileIcon } from 'lucide-react';
|
|
import type { NostrEvent } from '@nostrify/nostrify';
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
import { ImageGallery } from '@/components/ImageGallery';
|
|
import { VideoPlayer } from '@/components/VideoPlayer';
|
|
import { AudioVisualizer } from '@/components/AudioVisualizer';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { getDisplayName } from '@/lib/getDisplayName';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { sanitizeUrl } from '@/lib/sanitizeUrl';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
/** Extract the first value of a tag by name. */
|
|
function getTag(tags: string[][], name: string): string | undefined {
|
|
return tags.find(([n]) => n === name)?.[1];
|
|
}
|
|
|
|
/** Format bytes into a human-readable string. */
|
|
function formatBytes(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/** YouTube-style description card rendered below media. */
|
|
function DescriptionCard({ title, text }: { title?: string; text?: string }) {
|
|
if (!title && !text) return null;
|
|
return (
|
|
<div className="mt-2.5 rounded-xl bg-secondary/50 px-3.5 py-2.5">
|
|
{title && (
|
|
<p className="text-base font-semibold text-foreground break-words">{title}</p>
|
|
)}
|
|
{text && (
|
|
<p className={cn('text-sm leading-relaxed text-muted-foreground break-words', title && 'mt-1')}>
|
|
{text}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Inner component for audio events — needs author data for avatar. */
|
|
function AudioFileContent({
|
|
event,
|
|
url,
|
|
mime,
|
|
description,
|
|
}: {
|
|
event: NostrEvent;
|
|
url: string;
|
|
mime: string;
|
|
description: string | undefined;
|
|
}) {
|
|
const author = useAuthor(event.pubkey);
|
|
const metadata = author.data?.metadata;
|
|
const displayName = getDisplayName(metadata, event.pubkey) ?? genUserName(event.pubkey);
|
|
|
|
return (
|
|
<div className="mt-3">
|
|
<AudioVisualizer
|
|
src={url}
|
|
mime={mime}
|
|
avatarUrl={metadata?.picture}
|
|
avatarFallback={displayName[0]?.toUpperCase() ?? '?'}
|
|
/>
|
|
{description && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FileMetadataContentProps {
|
|
event: NostrEvent;
|
|
/** If true, render a more compact version for feed cards. */
|
|
compact?: boolean;
|
|
}
|
|
|
|
/**
|
|
* Renders the content of a NIP-94 (kind 1063) file metadata event.
|
|
*
|
|
* Media renders directly, and the description appears in a separate
|
|
* rounded card below it (similar to YouTube's description box).
|
|
*/
|
|
export function FileMetadataContent({ event, compact }: FileMetadataContentProps) {
|
|
const url = sanitizeUrl(getTag(event.tags, 'url'));
|
|
const mime = getTag(event.tags, 'm') ?? '';
|
|
const alt = getTag(event.tags, 'alt');
|
|
const dim = getTag(event.tags, 'dim');
|
|
const blurhash = getTag(event.tags, 'blurhash');
|
|
const thumb = getTag(event.tags, 'thumb') ?? getTag(event.tags, 'image');
|
|
const size = getTag(event.tags, 'size');
|
|
|
|
if (!url) return null;
|
|
|
|
const description = event.content || undefined;
|
|
const altText = alt ?? undefined;
|
|
const fileName = url.split('/').pop() ?? 'file';
|
|
const sizeStr = size ? formatBytes(Number(size)) : undefined;
|
|
|
|
// ── Image ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('image/')) {
|
|
const imetaMap = (dim || blurhash)
|
|
? new Map([[url, { dim, blurhash }]])
|
|
: undefined;
|
|
return (
|
|
<div className="mt-3">
|
|
<ImageGallery images={[url]} imetaMap={imetaMap} />
|
|
{description && !compact && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Video ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('video/')) {
|
|
return (
|
|
<div className="mt-3">
|
|
<VideoPlayer src={url} poster={thumb} dim={dim} blurhash={blurhash} title={altText} />
|
|
{description && !compact && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ── Audio ───────────────────────────────────────────────────────────
|
|
if (mime.startsWith('audio/')) {
|
|
return <AudioFileContent event={event} url={url} mime={mime} description={description} />;
|
|
}
|
|
|
|
// ── Fallback: generic file ──────────────────────────────────────────
|
|
const displayName = altText ?? fileName;
|
|
const mimeLabel = mime ? mime.split('/').pop()?.toUpperCase() : undefined;
|
|
|
|
return (
|
|
<div className="mt-3">
|
|
<div className="rounded-2xl border border-border overflow-hidden">
|
|
<div className="flex items-center gap-3.5 p-4">
|
|
<div className="flex items-center justify-center size-12 rounded-xl bg-muted shrink-0">
|
|
<FileIcon className="size-6 text-muted-foreground" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-semibold truncate">{displayName}</p>
|
|
<p className="text-xs text-muted-foreground mt-0.5">
|
|
{[mimeLabel, sizeStr].filter(Boolean).join(' · ') || 'File'}
|
|
</p>
|
|
</div>
|
|
<Button variant="outline" size="sm" className="shrink-0 rounded-full gap-1.5" asChild>
|
|
<a href={url} download>
|
|
<Download className="size-3.5" />
|
|
Download
|
|
</a>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
{description && <DescriptionCard text={description} />}
|
|
</div>
|
|
);
|
|
}
|