diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 0294fa76..292c27c8 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Link } from 'react-router-dom'; -import { BookOpen, ExternalLink, Globe, MapPin, User, Users } from 'lucide-react'; +import { BookOpen, ExternalLink, FileText, Globe, MapPin, Play, User, Users } from 'lucide-react'; import { nip19 } from 'nostr-tools'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Skeleton } from '@/components/ui/skeleton'; @@ -16,6 +16,8 @@ import { genUserName } from '@/lib/genUserName'; import { getCountryInfo, getWikipediaTitle } from '@/lib/countries'; import { useWikipediaSummary } from '@/hooks/useWikipediaSummary'; import { parseExternalUri, formatIsbn } from '@/lib/externalContent'; +import { EXTRA_KINDS } from '@/lib/extraKinds'; +import { CONTENT_KIND_ICONS } from '@/lib/sidebarItems'; // --------------------------------------------------------------------------- // Full-size content headers (used on /i/ page) @@ -568,3 +570,124 @@ export function ProfilePreview({ pubkey }: { pubkey: string }) { ); } + +// --------------------------------------------------------------------------- +// Addressable event preview (vines, music, articles, etc.) +// --------------------------------------------------------------------------- + +/** Extract a thumbnail URL from an addressable event's tags. */ +function extractThumbnail(tags: string[][]): string | undefined { + // 1. Explicit image/thumb tag + const imageTag = tags.find(([n]) => n === 'image' || n === 'thumb')?.[1]; + if (imageTag) return imageTag; + + // 2. imeta tag (used by vines / kind 34236) + const imetaTag = tags.find(([n]) => n === 'imeta'); + if (imetaTag) { + for (let i = 1; i < imetaTag.length; i++) { + const part = imetaTag[i]; + if (part.startsWith('image ')) return part.slice(6); + } + } + + return undefined; +} + +/** Check if an event has video content (imeta with url containing video indicators). */ +function hasVideo(tags: string[][]): boolean { + const imetaTag = tags.find(([n]) => n === 'imeta'); + if (!imetaTag) return false; + for (let i = 1; i < imetaTag.length; i++) { + const part = imetaTag[i]; + if (part.startsWith('url ') || part.startsWith('m video/')) return true; + } + return false; +} + +export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey: string; identifier: string } }) { + const { data: event, isLoading } = useAddrEvent(addr); + const author = useAuthor(addr.pubkey); + const authorMeta = author.data?.metadata; + const authorName = authorMeta?.name ?? genUserName(addr.pubkey); + + const kindDef = useMemo( + () => EXTRA_KINDS.find((d) => d.kind === addr.kind || d.subKinds?.some((s) => s.kind === addr.kind)), + [addr.kind], + ); + const kindLabel = useMemo(() => { + if (kindDef) return kindDef.label; + const sub = EXTRA_KINDS.flatMap((d) => d.subKinds ?? []).find((s) => s.kind === addr.kind); + if (sub) return sub.label; + return `Kind ${addr.kind}`; + }, [kindDef, addr.kind]); + + const KindIcon = useMemo(() => { + if (kindDef?.id) return CONTENT_KIND_ICONS[kindDef.id] ?? FileText; + return FileText; + }, [kindDef]); + + const title = event?.tags.find(([n]) => n === 'title')?.[1] + || event?.tags.find(([n]) => n === 'name')?.[1] + || event?.tags.find(([n]) => n === 'd')?.[1] + || kindLabel; + const thumbnail = event ? extractThumbnail(event.tags) : undefined; + const isVideo = event ? hasVideo(event.tags) : false; + + const link = useMemo(() => { + return `/${nip19.naddrEncode({ kind: addr.kind, pubkey: addr.pubkey, identifier: addr.identifier })}`; + }, [addr]); + + if (isLoading) { + return ( +
+ {title} +
+