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 ( +
+
+ +
+ + + +
+
+
+ ); + } + + return ( + + {thumbnail ? ( +
+ {title} + {isVideo && ( +
+ +
+ )} +
+ ) : ( +
+ +
+ )} + +
+
+ + {kindLabel} + · + {authorName} +
+

+ {title} +

+
+ + ); +} diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 0edbe44f..9cd1b57b 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -92,7 +92,7 @@ import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { ContentWarningGuard } from '@/components/ContentWarningGuard'; import { MutedContentGuard } from '@/components/MutedContentGuard'; -import { ExternalContentPreview, ProfilePreview, CommunityPreview } from '@/components/ExternalContentHeader'; +import { ExternalContentPreview, ProfilePreview, CommunityPreview, AddressableEventPreview } from '@/components/ExternalContentHeader'; import { getParentEventId, isReplyEvent } from '@/lib/nostrEvents'; import { EmojiPackContent } from '@/components/EmojiPackContent'; import { CommunityContent } from '@/components/CommunityContent'; @@ -896,6 +896,23 @@ function PostDetailContent({ event }: { event: NostrEvent }) { return { kind, pubkey, identifier }; }, [event, isComment]); + // For kind 1111 comments on any other addressable event (vines, music, etc.), + // extract the addr for a generic preview — only if not already handled above. + const addrRoot = useMemo(() => { + if (!isComment || externalIdentifier || profileRootPubkey || communityRootAddr) return undefined; + const kTag = event.tags.find(([n]) => n === 'K')?.[1]; + if (!kTag) return undefined; + const kind = parseInt(kTag, 10); + if (isNaN(kind)) return undefined; + const aTag = event.tags.find(([n]) => n === 'A')?.[1]; + if (!aTag) return undefined; + const parts = aTag.split(':'); + const pubkey = parts[1]; + const identifier = parts.slice(2).join(':'); + if (!pubkey) return undefined; + return { kind, pubkey, identifier }; + }, [event, isComment, externalIdentifier, profileRootPubkey, communityRootAddr]); + // Keep the focused post pinned to top while ancestor content loads above it. // A ResizeObserver on the ancestor container re-scrolls on every layout shift // (image loads, skeleton→content swaps) for the first few seconds. @@ -955,6 +972,9 @@ function PostDetailContent({ event }: { event: NostrEvent }) { {communityRootAddr && ( )} + {addrRoot && ( + + )} {/* Book context for reviews (kind 31985) and posts that tag a book */} {bookIsbn && (