Render link previews inline where URLs appear in post content

Move LinkPreview rendering from a fixed position below the post into
NoteContent itself, so previews appear exactly where the URL was in
the original text.

- Rewrite NoteContent to use a token-based architecture: useMemo
  produces typed tokens (text, link-preview, mention, nostr-link,
  hashtag), then the render phase maps tokens to components
- Non-media URLs produce a `link-preview` token that renders an
  inline <LinkPreview> card at that position in the content flow
- Remove separate LinkPreview blocks from NoteCard and PostDetailPage
  since previews are now handled by NoteContent
- Remove unused extractPreviewUrl helper from useLinkPreview hook

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy
2026-02-16 23:00:58 -06:00
parent 00d5024218
commit dd02f2ff27
4 changed files with 86 additions and 110 deletions
-8
View File
@@ -2,10 +2,8 @@ import { Link, useNavigate } from 'react-router-dom';
import { MessageCircle, Repeat2, Heart, Zap, MoreHorizontal } from 'lucide-react';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { NoteContent } from '@/components/NoteContent';
import { LinkPreview } from '@/components/LinkPreview';
import { useAuthor } from '@/hooks/useAuthor';
import { useEventStats } from '@/hooks/useTrending';
import { extractPreviewUrl } from '@/hooks/useLinkPreview';
import { genUserName } from '@/lib/genUserName';
import { timeAgo } from '@/lib/timeAgo';
import { cn } from '@/lib/utils';
@@ -43,7 +41,6 @@ export function NoteCard({ event, className }: NoteCardProps) {
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const neventId = useMemo(() => nip19.neventEncode({ id: event.id, author: event.pubkey }), [event.id, event.pubkey]);
const images = useMemo(() => extractImages(event.content), [event.content]);
const previewUrl = useMemo(() => extractPreviewUrl(event.content), [event.content]);
const { data: stats } = useEventStats(event.id);
const [liked, setLiked] = useState(false);
const [moreMenuOpen, setMoreMenuOpen] = useState(false);
@@ -132,11 +129,6 @@ export function NoteCard({ event, className }: NoteCardProps) {
</div>
)}
{/* Link preview */}
{previewUrl && (
<LinkPreview url={previewUrl} className="mt-3" />
)}
{/* Action buttons */}
<div className="flex items-center justify-between mt-2 max-w-md -ml-2">
{/* Reply */}
+86 -76
View File
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
import { nip19 } from 'nostr-tools';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { LinkPreview } from '@/components/LinkPreview';
import { cn } from '@/lib/utils';
interface NoteContentProps {
@@ -14,120 +15,129 @@ interface NoteContentProps {
/** Regex to detect media file URLs (images, video, audio, etc.) that are rendered as embeds. */
const MEDIA_URL_REGEX = /https?:\/\/[^\s]+\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|mp3|ogg|wav|pdf)(\?[^\s]*)?/i;
/** A parsed token from note content. */
type ContentToken =
| { type: 'text'; value: string }
| { type: 'link-preview'; url: string }
| { type: 'mention'; pubkey: string }
| { type: 'nostr-link'; id: string; raw: string }
| { type: 'hashtag'; tag: string; raw: string };
/** Parses content of text note events so that URLs and hashtags are linkified. */
export function NoteContent({
event,
className,
}: NoteContentProps) {
// Process the content to render mentions, links, etc.
const content = useMemo(() => {
event,
className,
}: NoteContentProps) {
// Parse content into tokens (pure data, no hooks)
const tokens = useMemo(() => {
const text = event.content;
// Regex to find URLs, Nostr references, and hashtags
const regex = /(https?:\/\/[^\s]+)|nostr:(npub1|note1|nprofile1|nevent1)([023456789acdefghjklmnpqrstuvwxyz]+)|(#\w+)/g;
const parts: React.ReactNode[] = [];
const result: ContentToken[] = [];
let lastIndex = 0;
let match: RegExpExecArray | null;
let keyCounter = 0;
while ((match = regex.exec(text)) !== null) {
const [fullMatch, url, nostrPrefix, nostrData, hashtag] = match;
const index = match.index;
// Add text before this match
if (index > lastIndex) {
parts.push(text.substring(lastIndex, index));
result.push({ type: 'text', value: text.substring(lastIndex, index) });
}
if (url) {
// Skip media URLs — they are rendered as embedded previews by the parent
if (MEDIA_URL_REGEX.test(url)) {
lastIndex = index + fullMatch.length;
continue;
}
// Skip non-media URLs — they are rendered as link preview cards by the parent
lastIndex = index + fullMatch.length;
continue;
// Non-media URL render as link preview card in-place
result.push({ type: 'link-preview', url });
} else if (nostrPrefix && nostrData) {
// Handle Nostr references
try {
const nostrId = `${nostrPrefix}${nostrData}`;
const decoded = nip19.decode(nostrId);
if (decoded.type === 'npub') {
const pubkey = decoded.data;
parts.push(
<NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
result.push({ type: 'mention', pubkey: decoded.data });
} else if (decoded.type === 'nprofile') {
const pubkey = decoded.data.pubkey;
parts.push(
<NostrMention key={`mention-${keyCounter++}`} pubkey={pubkey} />
);
result.push({ type: 'mention', pubkey: decoded.data.pubkey });
} else {
// For other types, just show as a link
parts.push(
<Link
key={`nostr-${keyCounter++}`}
to={`/${nostrId}`}
className="text-primary hover:underline break-all"
>
{fullMatch}
</Link>
);
result.push({ type: 'nostr-link', id: nostrId, raw: fullMatch });
}
} catch {
// If decoding fails, just render as text
parts.push(fullMatch);
result.push({ type: 'text', value: fullMatch });
}
} else if (hashtag) {
// Handle hashtags
const tag = hashtag.slice(1); // Remove the #
parts.push(
<Link
key={`hashtag-${keyCounter++}`}
to={`/t/${tag}`}
className="text-primary hover:underline"
>
{hashtag}
</Link>
);
const tag = hashtag.slice(1);
result.push({ type: 'hashtag', tag, raw: hashtag });
}
lastIndex = index + fullMatch.length;
}
// Add any remaining text
if (lastIndex < text.length) {
parts.push(text.substring(lastIndex));
}
// If no special content was found, just use the plain text
if (parts.length === 0) {
parts.push(text);
result.push({ type: 'text', value: text.substring(lastIndex) });
}
// Trim leading/trailing whitespace from string parts at the edges
// (image URLs stripped from the end can leave trailing newlines)
if (parts.length > 0) {
const first = parts[0];
if (typeof first === 'string') {
parts[0] = first.replace(/^\s+/, '');
// If no special content was found, just use the plain text
if (result.length === 0) {
result.push({ type: 'text', value: text });
}
// Trim leading/trailing whitespace from text tokens at the edges
// (stripped URLs can leave trailing newlines)
if (result.length > 0) {
const first = result[0];
if (first.type === 'text') {
first.value = first.value.replace(/^\s+/, '');
}
const lastIdx = parts.length - 1;
const last = parts[lastIdx];
if (typeof last === 'string') {
parts[lastIdx] = last.replace(/\s+$/, '');
const last = result[result.length - 1];
if (last.type === 'text') {
last.value = last.value.replace(/\s+$/, '');
}
}
return parts;
return result;
}, [event]);
return (
<div className={cn("whitespace-pre-wrap break-words", className)}>
{content.length > 0 ? content : event.content}
<div className={cn('whitespace-pre-wrap break-words', className)}>
{tokens.map((token, i) => {
switch (token.type) {
case 'text':
return <span key={i}>{token.value}</span>;
case 'link-preview':
return <LinkPreview key={i} url={token.url} className="my-2" />;
case 'mention':
return <NostrMention key={i} pubkey={token.pubkey} />;
case 'nostr-link':
return (
<Link
key={i}
to={`/${token.id}`}
className="text-primary hover:underline break-all"
>
{token.raw}
</Link>
);
case 'hashtag':
return (
<Link
key={i}
to={`/t/${token.tag}`}
className="text-primary hover:underline"
>
{token.raw}
</Link>
);
}
})}
</div>
);
}
@@ -140,16 +150,16 @@ function NostrMention({ pubkey }: { pubkey: string }) {
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
return (
<Link
<Link
to={`/${npub}`}
className={cn(
"font-medium hover:underline",
hasRealName
? "text-primary"
: "text-muted-foreground hover:text-foreground"
'font-medium hover:underline',
hasRealName
? 'text-primary'
: 'text-muted-foreground hover:text-foreground',
)}
>
@{displayName}
</Link>
);
}
}
-18
View File
@@ -2,9 +2,6 @@ import { useQuery } from '@tanstack/react-query';
const CORS_PROXY = 'https://proxy.shakespeare.diy/?url=';
/** Regex to match media file URLs that should NOT get link previews. */
const MEDIA_URL_REGEX = /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov|mp3|ogg|wav|pdf)(\?[^\s]*)?$/i;
export interface LinkPreviewData {
url: string;
title?: string;
@@ -14,21 +11,6 @@ export interface LinkPreviewData {
favicon?: string;
}
/** Extract the first non-media URL from note content. */
export function extractPreviewUrl(content: string): string | null {
const urlRegex = /https?:\/\/[^\s]+/g;
let match: RegExpExecArray | null;
while ((match = urlRegex.exec(content)) !== null) {
const url = match[0];
if (!MEDIA_URL_REGEX.test(url)) {
return url;
}
}
return null;
}
/** Parse OG meta tags from raw HTML. */
function parseOpenGraph(html: string, pageUrl: string): LinkPreviewData {
const data: LinkPreviewData = { url: pageUrl };
-8
View File
@@ -9,7 +9,6 @@ import { MainLayout } from '@/components/MainLayout';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Skeleton } from '@/components/ui/skeleton';
import { NoteContent } from '@/components/NoteContent';
import { LinkPreview } from '@/components/LinkPreview';
import { NoteCard } from '@/components/NoteCard';
import { NoteMoreMenu } from '@/components/NoteMoreMenu';
import { ReplyComposeModal } from '@/components/ReplyComposeModal';
@@ -18,7 +17,6 @@ import { useEvent } from '@/hooks/useEvent';
import { useReplies } from '@/hooks/useReplies';
import { useAuthor } from '@/hooks/useAuthor';
import { useEventStats } from '@/hooks/useTrending';
import { extractPreviewUrl } from '@/hooks/useLinkPreview';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import NotFound from './NotFound';
@@ -112,7 +110,6 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
const nip05 = metadata?.nip05;
const npub = useMemo(() => nip19.npubEncode(event.pubkey), [event.pubkey]);
const images = useMemo(() => extractImages(event.content), [event.content]);
const previewUrl = useMemo(() => extractPreviewUrl(event.content), [event.content]);
const { data: stats } = useEventStats(event.id);
const { data: replies, isLoading: repliesLoading } = useReplies(event.id);
const [liked, setLiked] = useState(false);
@@ -188,11 +185,6 @@ function PostDetailContent({ event }: { event: NostrEvent }) {
</div>
)}
{/* Link preview */}
{previewUrl && (
<LinkPreview url={previewUrl} className="mt-3" />
)}
{/* Stats row: "2 Reposts 1 👍" left, "Feb 16, 2026, 6:44 PM" right — Ditto style */}
{hasStats && (
<div className="flex items-center gap-x-3 py-2.5 mt-3 text-sm text-muted-foreground">