From ebca2ec761ab3954246fd5f53701dcab4ddfa8f0 Mon Sep 17 00:00:00 2001 From: Alex Gleason Date: Fri, 27 Feb 2026 23:37:20 -0600 Subject: [PATCH] Extract shared ExternalContentHeader and show parent preview on comment detail Refactor the external content header components (URL, Book, Country) and helpers out of ExternalContentPage into a shared module at ExternalContentHeader.tsx. Both the /i/ page and PostDetailPage now import from this shared module. Add a compact ExternalContentPreview component that shows a rich inline preview (thumbnail, title, favicon) of the external content being commented on. This is displayed above the focused comment on the nevent detail page for kind 1111 comments with I tags, similar to how AncestorThread shows the parent for kind 1 replies. --- src/components/ExternalContentHeader.tsx | 583 +++++++++++++++++++++++ src/pages/ExternalContentPage.tsx | 417 +--------------- src/pages/PostDetailPage.tsx | 12 + 3 files changed, 604 insertions(+), 408 deletions(-) create mode 100644 src/components/ExternalContentHeader.tsx diff --git a/src/components/ExternalContentHeader.tsx b/src/components/ExternalContentHeader.tsx new file mode 100644 index 00000000..e9cf6a0a --- /dev/null +++ b/src/components/ExternalContentHeader.tsx @@ -0,0 +1,583 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { BookOpen, ExternalLink, Globe, MapPin } from 'lucide-react'; +import { Skeleton } from '@/components/ui/skeleton'; +import { ExternalFavicon } from '@/components/ExternalFavicon'; +import { YouTubeEmbed } from '@/components/YouTubeEmbed'; +import { useLinkPreview } from '@/hooks/useLinkPreview'; +import { useBookInfo } from '@/hooks/useBookInfo'; +import { getCountryInfo } from '@/lib/countries'; +import { cn } from '@/lib/utils'; + +// --------------------------------------------------------------------------- +// Types & helpers +// --------------------------------------------------------------------------- + +/** Parsed external content identifier with its type. */ +export type ExternalContent = + | { type: 'url'; value: string } + | { type: 'isbn'; value: string } + | { type: 'iso3166'; value: string; code: string } + | { type: 'unknown'; value: string }; + +/** Parse a URI string into a typed external content object. */ +export function parseExternalUri(uri: string): ExternalContent { + if (uri.startsWith('isbn:')) { + return { type: 'isbn', value: uri }; + } + if (uri.startsWith('iso3166:')) { + const code = uri.slice('iso3166:'.length); + return { type: 'iso3166', value: uri, code }; + } + if (uri.startsWith('http://') || uri.startsWith('https://')) { + return { type: 'url', value: uri }; + } + return { type: 'unknown', value: uri }; +} + +/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */ +export function extractYouTubeId(url: string): string | null { + try { + const u = new URL(url); + if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') { + return u.searchParams.get('v'); + } + if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) { + return u.pathname.split('/')[2] || null; + } + if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) { + return u.pathname.split('/')[2] || null; + } + if (u.hostname === 'youtu.be') { + return u.pathname.slice(1) || null; + } + } catch { + // not a valid URL + } + return null; +} + +/** Format an ISBN with hyphens for display (simplified). */ +export function formatIsbn(isbn: string): string { + const digits = isbn.replace(/\D/g, ''); + if (digits.length === 13) { + return `${digits.slice(0, 3)}-${digits.slice(3, 4)}-${digits.slice(4, 9)}-${digits.slice(9, 12)}-${digits.slice(12)}`; + } + if (digits.length === 10) { + return `${digits.slice(0, 1)}-${digits.slice(1, 5)}-${digits.slice(5, 9)}-${digits.slice(9)}`; + } + return isbn; +} + +/** Get a short label for the content type. */ +export function headerLabel(content: ExternalContent): string { + switch (content.type) { + case 'url': + if (extractYouTubeId(content.value)) return 'YouTube'; + try { + return new URL(content.value).hostname.replace(/^www\./, ''); + } catch { + return 'Web Page'; + } + case 'isbn': + return 'Book'; + case 'iso3166': + return getCountryInfo(content.code)?.name ?? 'Country'; + default: + return 'External Content'; + } +} + +/** Get a page title for SEO. */ +export function seoTitle(content: ExternalContent): string { + switch (content.type) { + case 'url': + try { + return `${new URL(content.value).hostname.replace(/^www\./, '')} | Ditto`; + } catch { + return 'Web Page | Ditto'; + } + case 'isbn': { + const isbn = content.value.replace('isbn:', ''); + return `Book (ISBN ${isbn}) | Ditto`; + } + case 'iso3166': { + const info = getCountryInfo(content.code); + return info ? `${info.name} | Ditto` : 'Country | Ditto'; + } + default: + return 'External Content | Ditto'; + } +} + +// --------------------------------------------------------------------------- +// Full-size content headers (used on /i/ page) +// --------------------------------------------------------------------------- + +export function UrlContentHeader({ url }: { url: string }) { + const youtubeId = useMemo(() => extractYouTubeId(url), [url]); + const { data, isLoading } = useLinkPreview(url); + + const domain = useMemo(() => { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } + }, [url]); + + if (isLoading && !youtubeId) { + return ( +
+ +
+ + + +
+
+ ); + } + + const title = data?.title; + const author = data?.author_name; + const providerName = data?.provider_name || domain; + + if (youtubeId) { + return ( +
+ + + +
+ + {providerName} + +
+ + {title && ( +

+ {title} +

+ )} + + {author && ( +

+ by {author} +

+ )} +
+
+ ); + } + + const image = data?.thumbnail_url; + + return ( + + {image && ( +
+ { + (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; + }} + /> +
+ )} + +
+
+ + {providerName} + +
+ + {title && ( +

+ {title} +

+ )} + + {!title && ( +

+ {url} +

+ )} + + {author && ( +

+ by {author} +

+ )} +
+
+ ); +} + +export function BookContentHeader({ isbn }: { isbn: string }) { + const rawIsbn = isbn.replace('isbn:', ''); + const { data: book, isLoading } = useBookInfo(rawIsbn); + const displayIsbn = formatIsbn(rawIsbn); + + if (isLoading) { + return ( +
+
+ +
+ + + + +
+ + +
+
+
+
+ ); + } + + const coverUrl = book?.cover?.large || book?.cover?.medium; + const authors = book?.authors?.map((a) => a.name).join(', '); + const publishers = book?.publishers?.map((p) => p.name).join(', '); + + return ( +
+
+
+ {coverUrl ? ( +
+ {book?.title { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> +
+ ) : ( +
+ +
+ )} + +
+
+ + ISBN {displayIsbn} +
+ +

+ {book?.title || 'Unknown Book'} +

+ + {authors && ( +

+ by {authors} +

+ )} + +
+ {book?.publish_date && ( + {book.publish_date} + )} + {publishers && ( + {publishers} + )} + {book?.number_of_pages && ( + {book.number_of_pages} pages + )} +
+ + {book?.subjects && book.subjects.length > 0 && ( +
+ {book.subjects.map((s) => ( + + {s.name} + + ))} +
+ )} +
+
+
+ +
+ + + View on OpenLibrary + + +
+
+ ); +} + +export function CountryContentHeader({ code }: { code: string }) { + const info = getCountryInfo(code); + + if (!info) { + return ( +
+ +

Unknown country code: {code}

+
+ ); + } + + return ( +
+
+
+ + {info.flag} + +
+
+ + ISO 3166 {info.subdivision ? `(${info.subdivision})` : `(${code.toUpperCase()})`} +
+

+ {info.name} +

+ {info.subdivision && ( +

+ Subdivision: {info.subdivision} +

+ )} +
+
+
+ +
+ + + View on Wikipedia + + +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Compact preview (used on nevent detail pages for kind 1111 comments) +// --------------------------------------------------------------------------- + +/** + * Compact preview of external content, shown above a kind 1111 comment + * on its detail page. Links to the full /i/ page. + */ +export function ExternalContentPreview({ identifier }: { identifier: string }) { + const content = useMemo(() => parseExternalUri(identifier), [identifier]); + const link = `/i/${encodeURIComponent(identifier)}`; + + switch (content.type) { + case 'url': + return ; + case 'isbn': + return ; + case 'iso3166': + return ; + default: + return ( + +
+ + {identifier} +
+ + ); + } +} + +function UrlPreview({ url, link }: { url: string; link: string }) { + const youtubeId = useMemo(() => extractYouTubeId(url), [url]); + const { data, isLoading } = useLinkPreview(url); + + const domain = useMemo(() => { + try { + return new URL(url).hostname.replace(/^www\./, ''); + } catch { + return url; + } + }, [url]); + + const title = data?.title; + const image = data?.thumbnail_url; + const providerName = data?.provider_name || domain; + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + // YouTube gets a thumbnail from the video ID + const thumbnail = youtubeId + ? `https://img.youtube.com/vi/${youtubeId}/mqdefault.jpg` + : image; + + return ( + + {thumbnail ? ( + { + (e.currentTarget as HTMLElement).style.display = 'none'; + }} + /> + ) : ( +
+ +
+ )} + +
+
+ + {providerName} +
+

+ {title || url} +

+
+ + + + ); +} + +function BookPreview({ isbn, link }: { isbn: string; link: string }) { + const rawIsbn = isbn.replace('isbn:', ''); + const { data: book, isLoading } = useBookInfo(rawIsbn); + + if (isLoading) { + return ( +
+
+ +
+ + +
+
+
+ ); + } + + const coverUrl = book?.cover?.medium || book?.cover?.large; + const authors = book?.authors?.map((a) => a.name).join(', '); + + return ( + + {coverUrl ? ( + {book?.title + ) : ( +
+ +
+ )} + +
+
+ + Book +
+

+ {book?.title || `ISBN ${rawIsbn}`} +

+ {authors && ( +

+ by {authors} +

+ )} +
+ + + + ); +} + +function CountryPreview({ code, link }: { code: string; link: string }) { + const info = getCountryInfo(code); + + return ( + + + {info?.flag ?? '🌍'} + + +
+
+ + Country +
+

+ {info?.name ?? code} +

+
+ + + + ); +} diff --git a/src/pages/ExternalContentPage.tsx b/src/pages/ExternalContentPage.tsx index ecf394d7..d8f8cf3e 100644 --- a/src/pages/ExternalContentPage.tsx +++ b/src/pages/ExternalContentPage.tsx @@ -1,423 +1,24 @@ import { useMemo } from 'react'; import { useSeoMeta } from '@unhead/react'; -import { ArrowLeft, BookOpen, ExternalLink, Globe, MapPin, MessageSquare } from 'lucide-react'; +import { ArrowLeft, Globe, MessageSquare } from 'lucide-react'; import { Link, useLocation, useParams } from 'react-router-dom'; import { Skeleton } from '@/components/ui/skeleton'; import { NoteCard } from '@/components/NoteCard'; import { CommentForm } from '@/components/comments/CommentForm'; -import { ExternalFavicon } from '@/components/ExternalFavicon'; -import { YouTubeEmbed } from '@/components/YouTubeEmbed'; +import { + parseExternalUri, + headerLabel, + seoTitle, + UrlContentHeader, + BookContentHeader, + CountryContentHeader, +} from '@/components/ExternalContentHeader'; import { useComments } from '@/hooks/useComments'; -import { useLinkPreview } from '@/hooks/useLinkPreview'; -import { useBookInfo } from '@/hooks/useBookInfo'; import { useMuteList } from '@/hooks/useMuteList'; import { isEventMuted } from '@/lib/muteHelpers'; -import { getCountryInfo } from '@/lib/countries'; import { cn, STICKY_HEADER_CLASS } from '@/lib/utils'; import NotFound from './NotFound'; -/** Parsed external content identifier with its type. */ -type ExternalContent = - | { type: 'url'; value: string } - | { type: 'isbn'; value: string } - | { type: 'iso3166'; value: string; code: string } - | { type: 'unknown'; value: string }; - -/** Parse a URI string into a typed external content object. */ -function parseExternalUri(uri: string): ExternalContent { - // ISBN - "isbn:9780765382030" - if (uri.startsWith('isbn:')) { - return { type: 'isbn', value: uri, }; - } - - // ISO 3166 country/subdivision - "iso3166:US" or "iso3166:US-CA" - if (uri.startsWith('iso3166:')) { - const code = uri.slice('iso3166:'.length); - return { type: 'iso3166', value: uri, code }; - } - - // URL - starts with http:// or https:// - if (uri.startsWith('http://') || uri.startsWith('https://')) { - return { type: 'url', value: uri }; - } - - return { type: 'unknown', value: uri }; -} - -/** Extract a YouTube video ID from a URL, or null if not a YouTube link. */ -function extractYouTubeId(url: string): string | null { - try { - const u = new URL(url); - if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com' || u.hostname === 'm.youtube.com') && u.pathname === '/watch') { - return u.searchParams.get('v'); - } - if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/embed/')) { - return u.pathname.split('/')[2] || null; - } - if ((u.hostname === 'www.youtube.com' || u.hostname === 'youtube.com') && u.pathname.startsWith('/shorts/')) { - return u.pathname.split('/')[2] || null; - } - if (u.hostname === 'youtu.be') { - return u.pathname.slice(1) || null; - } - } catch { - // not a valid URL - } - return null; -} - -/** Format an ISBN with hyphens for display (simplified). */ -function formatIsbn(isbn: string): string { - const digits = isbn.replace(/\D/g, ''); - if (digits.length === 13) { - return `${digits.slice(0, 3)}-${digits.slice(3, 4)}-${digits.slice(4, 9)}-${digits.slice(9, 12)}-${digits.slice(12)}`; - } - if (digits.length === 10) { - return `${digits.slice(0, 1)}-${digits.slice(1, 5)}-${digits.slice(5, 9)}-${digits.slice(9)}`; - } - return isbn; -} - -// --------------------------------------------------------------------------- -// URL content header -// --------------------------------------------------------------------------- - -function UrlContentHeader({ url }: { url: string }) { - const youtubeId = useMemo(() => extractYouTubeId(url), [url]); - const { data, isLoading } = useLinkPreview(url); - - const domain = useMemo(() => { - try { - return new URL(url).hostname.replace(/^www\./, ''); - } catch { - return url; - } - }, [url]); - - if (isLoading && !youtubeId) { - return ( -
- -
- - - -
-
- ); - } - - const title = data?.title; - const author = data?.author_name; - const providerName = data?.provider_name || domain; - - // YouTube URLs get the interactive embed player - if (youtubeId) { - return ( - - ); - } - - // Generic URL link preview - const image = data?.thumbnail_url; - - return ( - - {image && ( -
- { - (e.currentTarget.parentElement as HTMLElement).style.display = 'none'; - }} - /> -
- )} - -
-
- - {providerName} - -
- - {title && ( -

- {title} -

- )} - - {!title && ( -

- {url} -

- )} - - {author && ( -

- by {author} -

- )} -
-
- ); -} - -// --------------------------------------------------------------------------- -// Book content header -// --------------------------------------------------------------------------- - -function BookContentHeader({ isbn }: { isbn: string }) { - const rawIsbn = isbn.replace('isbn:', ''); - const { data: book, isLoading } = useBookInfo(rawIsbn); - const displayIsbn = formatIsbn(rawIsbn); - - if (isLoading) { - return ( -
-
- -
- - - - -
- - -
-
-
-
- ); - } - - const coverUrl = book?.cover?.large || book?.cover?.medium; - const authors = book?.authors?.map((a) => a.name).join(', '); - const publishers = book?.publishers?.map((p) => p.name).join(', '); - - return ( -
-
-
- {/* Book cover */} - {coverUrl ? ( -
- {book?.title { - (e.currentTarget as HTMLElement).style.display = 'none'; - }} - /> -
- ) : ( -
- -
- )} - - {/* Book details */} -
-
- - ISBN {displayIsbn} -
- -

- {book?.title || 'Unknown Book'} -

- - {authors && ( -

- by {authors} -

- )} - -
- {book?.publish_date && ( - {book.publish_date} - )} - {publishers && ( - {publishers} - )} - {book?.number_of_pages && ( - {book.number_of_pages} pages - )} -
- - {book?.subjects && book.subjects.length > 0 && ( -
- {book.subjects.map((s) => ( - - {s.name} - - ))} -
- )} -
-
-
- - {/* OpenLibrary link */} - -
- ); -} - -// --------------------------------------------------------------------------- -// Country content header -// --------------------------------------------------------------------------- - -function CountryContentHeader({ code }: { code: string }) { - const info = getCountryInfo(code); - - if (!info) { - return ( -
- -

Unknown country code: {code}

-
- ); - } - - return ( -
-
-
- - {info.flag} - -
-
- - ISO 3166 {info.subdivision ? `(${info.subdivision})` : `(${code.toUpperCase()})`} -
-

- {info.name} -

- {info.subdivision && ( -

- Subdivision: {info.subdivision} -

- )} -
-
-
- - -
- ); -} - -// --------------------------------------------------------------------------- -// Page header label -// --------------------------------------------------------------------------- - -function headerLabel(content: ExternalContent): string { - switch (content.type) { - case 'url': - if (extractYouTubeId(content.value)) return 'YouTube'; - try { - return new URL(content.value).hostname.replace(/^www\./, ''); - } catch { - return 'Web Page'; - } - case 'isbn': - return 'Book'; - case 'iso3166': - return getCountryInfo(content.code)?.name ?? 'Country'; - default: - return 'External Content'; - } -} - -function seoTitle(content: ExternalContent): string { - switch (content.type) { - case 'url': - try { - return `${new URL(content.value).hostname.replace(/^www\./, '')} | Ditto`; - } catch { - return 'Web Page | Ditto'; - } - case 'isbn': { - const isbn = content.value.replace('isbn:', ''); - return `Book (ISBN ${isbn}) | Ditto`; - } - case 'iso3166': { - const info = getCountryInfo(content.code); - return info ? `${info.name} | Ditto` : 'Country | Ditto'; - } - default: - return 'External Content | Ditto'; - } -} - // --------------------------------------------------------------------------- // Main page component // --------------------------------------------------------------------------- diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx index 6182d770..0335b822 100644 --- a/src/pages/PostDetailPage.tsx +++ b/src/pages/PostDetailPage.tsx @@ -59,6 +59,7 @@ import { ProfileHoverCard } from '@/components/ProfileHoverCard'; import { useProfileUrl } from '@/hooks/useProfileUrl'; import { ContentWarningGuard } from '@/components/ContentWarningGuard'; import { MutedContentGuard } from '@/components/MutedContentGuard'; +import { ExternalContentPreview } from '@/components/ExternalContentHeader'; import { getParentEventId } from '@/lib/nostrEvents'; @@ -748,6 +749,12 @@ function PostDetailContent({ event }: { event: NostrEvent }) { const parentEventId = useMemo(() => isTextNote ? getParentEventId(event) : undefined, [event, isTextNote]); + // For kind 1111 comments on external content, extract the I tag for the parent preview + const externalIdentifier = useMemo(() => { + if (!isComment) return undefined; + return event.tags.find(([n]) => n === 'I')?.[1]; + }, [event, isComment]); + // 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. @@ -804,6 +811,11 @@ function PostDetailContent({ event }: { event: NostrEvent }) { )} + {/* External content preview for kind 1111 comments on URLs/books/etc. */} + {externalIdentifier && ( + + )} + {/* Main post — expanded Ditto-style view */}
{/* Author row */}