diff --git a/src/components/NoteCard.tsx b/src/components/NoteCard.tsx
index 54ae3097..c4dd254f 100644
--- a/src/components/NoteCard.tsx
+++ b/src/components/NoteCard.tsx
@@ -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) {
)}
- {/* Link preview */}
- {previewUrl && (
-
- )}
-
{/* Action buttons */}
{/* Reply */}
diff --git a/src/components/NoteContent.tsx b/src/components/NoteContent.tsx
index 794d753e..207fb3ff 100644
--- a/src/components/NoteContent.tsx
+++ b/src/components/NoteContent.tsx
@@ -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(
-
- );
+ result.push({ type: 'mention', pubkey: decoded.data });
} else if (decoded.type === 'nprofile') {
- const pubkey = decoded.data.pubkey;
- parts.push(
-
- );
+ result.push({ type: 'mention', pubkey: decoded.data.pubkey });
} else {
- // For other types, just show as a link
- parts.push(
-
- {fullMatch}
-
- );
+ 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(
-
- {hashtag}
-
- );
+ 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 (
-
- {content.length > 0 ? content : event.content}
+
+ {tokens.map((token, i) => {
+ switch (token.type) {
+ case 'text':
+ return {token.value};
+ case 'link-preview':
+ return ;
+ case 'mention':
+ return ;
+ case 'nostr-link':
+ return (
+
+ {token.raw}
+
+ );
+ case 'hashtag':
+ return (
+
+ {token.raw}
+
+ );
+ }
+ })}
);
}
@@ -140,16 +150,16 @@ function NostrMention({ pubkey }: { pubkey: string }) {
const displayName = author.data?.metadata?.name ?? genUserName(pubkey);
return (
-
@{displayName}
);
-}
\ No newline at end of file
+}
diff --git a/src/hooks/useLinkPreview.ts b/src/hooks/useLinkPreview.ts
index df5b82ed..e8c60b6f 100644
--- a/src/hooks/useLinkPreview.ts
+++ b/src/hooks/useLinkPreview.ts
@@ -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 };
diff --git a/src/pages/PostDetailPage.tsx b/src/pages/PostDetailPage.tsx
index a376dd73..39c72713 100644
--- a/src/pages/PostDetailPage.tsx
+++ b/src/pages/PostDetailPage.tsx
@@ -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 }) {
)}
- {/* Link preview */}
- {previewUrl && (
-
- )}
-
{/* Stats row: "2 Reposts 1 👍" left, "Feb 16, 2026, 6:44 PM" right — Ditto style */}
{hasStats && (