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:
@@ -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 */}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user