Files
eranos/src/components/FileMetadataContent.tsx
T
Alex Gleason 740fc1c63c Merge ditto/main into agora
Pulls in 387 commits from ditto/main while preserving Agora-specific
features. Where the two codebases diverged on the same concept, kept
the Agora side per project direction.

Kept Agora-specific:
- SparkWallet stack (over Ditto's nostr-derived Bitcoin wallet)
- Communities (NIP-72 + chat + members), Messages, Organizers,
  Actions, Verified, Appearance settings
- DMProviderWrapper, country/organizer moderation in NoteMoreMenu
- 'Agora' branding, pub.agora.app bundle ID, version 2.8.0
- Built-in theme system (src/themes.ts) only

Rejected from Ditto:
- All Blobbi virtual pet code (80+ files, route, provider, sidebar,
  kind labels, feed setting, NIP.md entries, CSS animations)
- Custom theme events (kinds 36767/16767) — ThemesPage, ThemeContent,
  active profile themes, theme snapshot recovery
- On-chain zaps (kind 8333) and the entire Bitcoin wallet implementation
  (useBitcoinWallet, bitcoin-signers, BitcoinContentHeader,
  bitcoinjs-lib / @bitcoinerlab/secp256k1 / ecpair / tiny-secp256k1)
- ZapSuccessScreen (depended on dropped bitcoin lib)

Pulled in from Ditto:
- .agents/skills/* (12 new specialized skills, slim AGENTS.md)
- @nostrify bumps to 0.52 / 0.6 / 0.37
- New routes/pages: Music, Podcasts, Videos, Vines, Wikipedia, Books,
  Bluesky, Archive, AIChat, Trends, Webxdc, Highlights, Decks, Emojis,
  Development, Treasures, Colors, Packs
- Birdstar feed integration (kinds 2473, 12473, 30621)
- Wikipedia/Wikidata/Scryfall lookup in ExternalContentPage
- release-notes CI job + extract-release-notes.mjs script
- nsite:// URI handling in feed/sidebar
- iOS fastlane setup
- src/lib/avatarShape.ts + Avatar shape prop (kept for new Music/People
  components that depend on it)

Preserved Agora's ABSOLUTE 'NEVER COMMIT' rule at the top of AGENTS.md
and dropped Ditto's contradicting 'Commit at the end of every task'
section.

Validation: npm run test passes (tsc, eslint, 40/40 vitest, vite build).
2026-05-13 18:35:03 -05:00

176 lines
6.5 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 { WebxdcEmbed } from '@/components/WebxdcEmbed';
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 webxdcId = getTag(event.tags, 'webxdc');
const dim = getTag(event.tags, 'dim');
const blurhash = getTag(event.tags, 'blurhash');
const thumb = getTag(event.tags, 'thumb') ?? getTag(event.tags, 'image');
const summary = getTag(event.tags, 'summary');
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;
// ── Webxdc app ──────────────────────────────────────────────────────
if (mime === 'application/x-webxdc') {
const appName = altText?.replace(/^Webxdc app:\s*/i, '') ?? summary ?? fileName.replace('.xdc', '');
return (
<div className="mt-3">
<WebxdcEmbed
url={url}
uuid={webxdcId}
icon={thumb}
showNameCard={false}
/>
<DescriptionCard title={appName} text={description} />
</div>
);
}
// ── 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>
);
}