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 (
+
+ );
+ }
+
+ 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 ? (
+
+

{
+ (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}
+
+ ))}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+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}
+
+ )}
+
+
+
+
+
+
+ );
+}
+
+// ---------------------------------------------------------------------------
+// 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
+
+
+ {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 ? (
-
-

{
- (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 */}