740fc1c63c
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).
176 lines
6.5 KiB
TypeScript
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>
|
|
);
|
|
}
|