From b401a21cbc88fb9732224847d7e2289637a059b4 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sat, 7 Mar 2026 12:46:11 -0600 Subject: [PATCH 1/2] Add context preview for comments on addressable events (vines, music, etc.) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When viewing a kind 1111 comment that replies to an addressable event like a vine (34236), music, or article, the detail page now shows a rich preview banner with thumbnail, kind label, title, and author — matching the existing pattern for URL, profile, and community previews. Works generically for all addressable event kinds by looking up the kind label from EXTRA_KINDS and extracting thumbnails from image/imeta tags. Closes #75 --- src/components/ExternalContentHeader.tsx | 118 ++++++++++++++++++++++- src/pages/PostDetailPage.tsx | 22 ++++- 2 files changed, 138 insertions(+), 2 deletions(-) diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 0294fa76..1c812af9 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,7 @@ 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'; // --------------------------------------------------------------------------- // Full-size content headers (used on /i/ page) @@ -568,3 +569,118 @@ 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 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 && ( From 73a048aeae744d8f4ff776b23a6db1d7be744742 Mon Sep 17 00:00:00 2001 From: Mary Kate Fain Date: Sat, 7 Mar 2026 12:50:45 -0600 Subject: [PATCH 2/2] Use sidebar icons for addressable event preview cards Look up the content type's icon from CONTENT_KIND_ICONS (same icons used in the sidebar) and display it in both the label row and as the fallback thumbnail placeholder. --- src/components/ExternalContentHeader.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx index 1c812af9..292c27c8 100644 --- a/src/components/ExternalContentHeader.tsx +++ b/src/components/ExternalContentHeader.tsx @@ -17,6 +17,7 @@ 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) @@ -620,6 +621,11 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey 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] @@ -667,12 +673,13 @@ export function AddressableEventPreview({ addr }: { addr: { kind: number; pubkey ) : (
- +
)}
+ {kindLabel} · {authorName}