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 && (