From 55d5b9b176586dbf483a6bb235824d03064f057b Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 10 Nov 2025 16:22:07 +0100 Subject: [PATCH] fixed markup --- src/components/Content/index.tsx | 69 ++++ src/components/Embedded/EmbeddedNormalUrl.tsx | 35 +- .../Note/AsciidocArticle/AsciidocArticle.tsx | 85 +++++ .../Note/MarkdownArticle/MarkdownArticle.tsx | 263 ++++++++++---- .../MarkdownArticle/remarkUnwrapImages.ts | 76 +++- .../Note/MarkdownArticle/remarkUnwrapNostr.ts | 60 ++++ src/components/Note/index.tsx | 9 +- src/components/ReplyNote/index.tsx | 7 +- .../UniversalContent/EnhancedContent.tsx | 333 ------------------ .../UniversalContent/ParsedContent.tsx | 156 -------- 10 files changed, 502 insertions(+), 591 deletions(-) create mode 100644 src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts delete mode 100644 src/components/UniversalContent/EnhancedContent.tsx delete mode 100644 src/components/UniversalContent/ParsedContent.tsx diff --git a/src/components/Content/index.tsx b/src/components/Content/index.tsx index ea4a32f..77a08ba 100644 --- a/src/components/Content/index.tsx +++ b/src/components/Content/index.tsx @@ -28,6 +28,7 @@ import Emoji from '../Emoji' import ImageGallery from '../ImageGallery' import MediaPlayer from '../MediaPlayer' import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer' +import WebPreview from '../WebPreview' import { toNote } from '@/lib/link' const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i @@ -91,6 +92,54 @@ export default function Content({ return { nodes, emojiInfos } }, [_content, event]) + // Extract HTTP/HTTPS links from content nodes (in order of appearance) for WebPreview cards at bottom + const contentLinks = useMemo(() => { + if (!nodes) return [] + const links: string[] = [] + const seenUrls = new Set() + + nodes.forEach((node) => { + if (node.type === 'url') { + const url = node.data + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + } + }) + + return links + }, [nodes]) + + // Extract HTTP/HTTPS links from r tags (excluding those already in content) + const tagLinks = useMemo(() => { + if (!event) return [] + const links: string[] = [] + const seenUrls = new Set() + + // Create a set of content link URLs for quick lookup + const contentLinkUrls = new Set(contentLinks) + + event.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .forEach(tag => { + const url = tag[1] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + // Only include if not already in content links and not already seen in tags + if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + }) + + return links + }, [event, contentLinks]) + if (!nodes || nodes.length === 0) { return null } @@ -354,6 +403,26 @@ export default function Content({ } return null })} + + {/* WebPreview cards for links from content (in order of appearance) */} + {contentLinks.length > 0 && ( +
+

Links

+ {contentLinks.map((url, index) => ( + + ))} +
+ )} + + {/* WebPreview cards for links from tags */} + {tagLinks.length > 0 && ( +
+

Related Links

+ {tagLinks.map((url, index) => ( + + ))} +
+ )} ) } diff --git a/src/components/Embedded/EmbeddedNormalUrl.tsx b/src/components/Embedded/EmbeddedNormalUrl.tsx index c8927c9..ff60009 100644 --- a/src/components/Embedded/EmbeddedNormalUrl.tsx +++ b/src/components/Embedded/EmbeddedNormalUrl.tsx @@ -1,25 +1,20 @@ -import { cleanUrl, isImage, isMedia } from '@/lib/url' -import WebPreview from '../WebPreview' +import { cleanUrl } from '@/lib/url' +import React from 'react' -export function EmbeddedNormalUrl({ url }: { url: string }) { +export function EmbeddedNormalUrl({ url, children }: { url: string; children?: React.ReactNode }) { // Clean tracking parameters from URLs before displaying/linking const cleanedUrl = cleanUrl(url) - // Don't show WebPreview for images or media - they're handled elsewhere - if (isImage(cleanedUrl) || isMedia(cleanedUrl)) { - return ( - e.stopPropagation()} - rel="noreferrer" - > - {cleanedUrl} - - ) - } - - // Show WebPreview for all regular URLs (including those with nostr identifiers) - return + // Render all URLs as green text links (like hashtags) - WebPreview cards shown at bottom + return ( + e.stopPropagation()} + rel="noreferrer noopener" + > + {children || cleanedUrl} + + ) } diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 48f372e..422a56c 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox' import Zoom from 'yet-another-react-lightbox/plugins/zoom' import { TImetaInfo } from '@/types' import { useMediaExtraction } from '@/hooks' +import WebPreview from '@/components/WebPreview' +import { cleanUrl, isImage, isMedia } from '@/lib/url' export default function AsciidocArticle({ event, @@ -217,6 +219,25 @@ export default function AsciidocArticle({ return () => clearTimeout(timeoutId) }, [parsedContent?.html]) + // Style external HTTP/HTTPS links as green (like hashtags) + useEffect(() => { + if (!contentRef.current || !parsedContent) return + + const styleExternalLinks = () => { + const links = contentRef.current?.querySelectorAll('a[href^="http://"], a[href^="https://"]') + links?.forEach((link) => { + const href = link.getAttribute('href') + if (href && !isImage(href) && !isMedia(href)) { + // Add green link styling + link.classList.add('text-green-600', 'dark:text-green-400', 'hover:text-green-700', 'dark:hover:text-green-300', 'hover:underline') + } + }) + } + + const timeoutId = setTimeout(styleExternalLinks, 100) + return () => clearTimeout(timeoutId) + }, [parsedContent?.html]) + // Add ToC return buttons to section headers useEffect(() => { if (!contentRef.current || !isArticleType || !parsedContent) return @@ -261,6 +282,50 @@ export default function AsciidocArticle({ // This includes images from tags, content, and parsed HTML const extractedMedia = useMediaExtraction(event, event.content) + // Extract HTTP/HTTPS links from parsed content (in order of appearance) for WebPreview cards at bottom + const contentLinks = useMemo(() => { + if (!parsedContent?.links) return [] + const links: string[] = [] + const seenUrls = new Set() + + parsedContent.links.forEach((link) => { + if (link.isExternal && (link.url.startsWith('http://') || link.url.startsWith('https://')) && !isImage(link.url) && !isMedia(link.url)) { + const cleaned = cleanUrl(link.url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + }) + + return links + }, [parsedContent?.links]) + + // Extract HTTP/HTTPS links from r tags (excluding those already in content) + const tagLinks = useMemo(() => { + const links: string[] = [] + const seenUrls = new Set() + + // Create a set of content link URLs for quick lookup + const contentLinkUrls = new Set(contentLinks) + + event.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .forEach(tag => { + const url = tag[1] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + // Only include if not already in content links and not already seen in tags + if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + }) + + return links + }, [event.tags, contentLinks]) + // Extract images from parsed HTML (after AsciiDoc processing) for carousel // This ensures we get images that were rendered in the HTML output const imagesInContent = useMemo(() => { @@ -482,6 +547,26 @@ export default function AsciidocArticle({ )} + + {/* WebPreview cards for links from content (in order of appearance) */} + {contentLinks.length > 0 && ( +
+

Links

+ {contentLinks.map((url, index) => ( + + ))} +
+ )} + + {/* WebPreview cards for links from tags */} + {tagLinks.length > 0 && ( +
+

Related Links

+ {tagLinks.map((url, index) => ( + + ))} +
+ )} ) } \ No newline at end of file diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index d2e47db..a7b9b21 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -9,7 +9,7 @@ import { useMediaExtraction } from '@/hooks' import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url' import { ExternalLink } from 'lucide-react' import { Event, kinds } from 'nostr-tools' -import { ExtendedKind } from '@/constants' +import { ExtendedKind, URL_REGEX } from '@/constants' import React, { useMemo, useEffect, useRef, useState } from 'react' import Markdown from 'react-markdown' import remarkGfm from 'remark-gfm' @@ -22,6 +22,7 @@ import NostrNode from './NostrNode' import { remarkNostr } from './remarkNostr' import { remarkHashtags } from './remarkHashtags' import { remarkUnwrapImages } from './remarkUnwrapImages' +import { remarkUnwrapNostr } from './remarkUnwrapNostr' import { preprocessMediaLinks } from './preprocessMediaLinks' import { Components } from './types' @@ -72,6 +73,70 @@ export default function MarkdownArticle({ return hashtags }, [event.content]) + // Create a stable key for contentHashtags to prevent unnecessary re-renders + const contentHashtagsKey = useMemo(() => { + return Array.from(contentHashtags).sort().join(',') + }, [contentHashtags]) + + // Extract HTTP/HTTPS links from content (in order of appearance) for WebPreview cards at bottom + const contentLinks = useMemo(() => { + const links: string[] = [] + const seenUrls = new Set() + + // Extract markdown links: [text](url) + const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g + let match + while ((match = markdownLinkRegex.exec(event.content)) !== null) { + const url = match[2] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + } + + // Extract raw URLs + while ((match = URL_REGEX.exec(event.content)) !== null) { + const url = match[0] + if (!isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + if (cleaned && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + } + + return links + }, [event.content]) + + // Extract HTTP/HTTPS links from r tags (excluding those already in content) + const tagLinks = useMemo(() => { + const links: string[] = [] + const seenUrls = new Set() + + // Create a set of content link URLs for quick lookup + const contentLinkUrls = new Set(contentLinks) + + event.tags + .filter(tag => tag[0] === 'r' && tag[1]) + .forEach(tag => { + const url = tag[1] + if ((url.startsWith('http://') || url.startsWith('https://')) && !isImage(url) && !isMedia(url)) { + const cleaned = cleanUrl(url) + // Only include if not already in content links and not already seen in tags + if (cleaned && !contentLinkUrls.has(cleaned) && !seenUrls.has(cleaned)) { + links.push(cleaned) + seenUrls.add(cleaned) + } + } + }) + + return links + }, [event.tags, contentLinks]) + // Extract media URLs that are in the content (so we don't render them twice) const mediaUrlsInContent = useMemo(() => { const urls = new Set() @@ -85,7 +150,14 @@ export default function MarkdownArticle({ // All images from useMediaExtraction are already cleaned and deduplicated // This includes images from content, tags, imeta, r tags, etc. - const allImages = extractedMedia.images + // Memoize with stable key based on image URLs to prevent unnecessary re-renders + const allImagesKey = useMemo(() => { + return extractedMedia.images.map(img => img.url).sort().join(',') + }, [extractedMedia.images]) + + const allImages = useMemo(() => { + return extractedMedia.images + }, [allImagesKey]) // Handle image clicks to open carousel const [lightboxIndex, setLightboxIndex] = useState(-1) @@ -141,7 +213,11 @@ export default function MarkdownArticle({ const components = useMemo( () => ({ - nostr: ({ rawText, bech32Id }) => , + nostr: ({ rawText, bech32Id }) => ( +
+ +
+ ), a: ({ href, children, ...props }) => { if (!href) { return @@ -275,14 +351,23 @@ export default function MarkdownArticle({ ) } - // Check if this is a regular HTTP/HTTPS URL that should show WebPreview + // For regular HTTP/HTTPS URLs, render as green text link (like hashtags) instead of WebPreview + // WebPreview cards will be shown at the bottom const cleanedHref = cleanUrl(href) const isRegularUrl = href.startsWith('http://') || href.startsWith('https://') - const shouldShowPreview = isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref) - // For regular URLs, show WebPreview directly (no wrapper) - if (shouldShowPreview) { - return + if (isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref)) { + return ( + + {children} + + ) } return ( @@ -298,50 +383,64 @@ export default function MarkdownArticle({ ) }, p: (props) => { - // Check if the paragraph contains an img element or Image component - // Since Image renders a div, we need to convert the paragraph to a div to avoid nesting issues + // Check if the paragraph contains block-level elements that cannot be inside

+ // Convert to

to avoid DOM nesting warnings const children = props.children const childrenArray = React.Children.toArray(children) - // Fast path: check if paragraph has only one child that might be an image - if (childrenArray.length === 1) { - const child = childrenArray[0] - if (React.isValidElement(child)) { - // Check for img type (string) before conversion, Image component after, or data attribute - if (child.type === 'img' || child.type === Image || child.props?.['data-markdown-image']) { - return
+ // Helper to check if a child is a block-level component + const isBlockLevel = (child: React.ReactNode): boolean => { + if (!React.isValidElement(child)) return false + + // Any div element is block-level and cannot be inside

+ if (child.type === 'div') { + return true + } + + // Check for known block-level components + if (child.type === 'img' || + child.type === Image || + child.type === MediaPlayer || + child.type === NostrNode || + child.props?.['data-markdown-image'] || + child.props?.['data-markdown-image-wrapper'] || + child.props?.['data-nostr-node'] || + child.props?.['data-embedded-note']) { + return true + } + + // Check children recursively (up to 3 levels deep for nested structures like EmbeddedNote -> MarkdownArticle) + if (child.props?.children) { + const grandchildren = React.Children.toArray(child.props.children) + if (grandchildren.some((gc: React.ReactNode) => isBlockLevel(gc))) { + return true } - // Check if child contains an img/image (for links wrapping images) - if (child.props?.children) { - const grandchildren = React.Children.toArray(child.props.children) - if (grandchildren.some((gc: React.ReactNode) => - React.isValidElement(gc) && - (gc.type === 'img' || gc.type === Image || gc.props?.['data-markdown-image']) - )) { - return

+ // Check one more level deep + for (const gc of grandchildren) { + if (React.isValidElement(gc) && gc.props?.children) { + const greatGrandchildren = React.Children.toArray(gc.props.children) + if (greatGrandchildren.some((ggc: React.ReactNode) => isBlockLevel(ggc))) { + return true + } + // Check one more level for deeply nested structures + for (const ggc of greatGrandchildren) { + if (React.isValidElement(ggc) && ggc.props?.children) { + const greatGreatGrandchildren = React.Children.toArray(ggc.props.children) + if (greatGreatGrandchildren.some((gggc: React.ReactNode) => isBlockLevel(gggc))) { + return true + } + } + } } } } + + return false } - // Check all children for images (for paragraphs with multiple children where one is an image) - for (const child of childrenArray) { - if (React.isValidElement(child)) { - // Direct image check - if (child.type === 'img' || child.type === Image || child.props?.['data-markdown-image']) { - return
- } - // One-level deep check for nested images (like in links) - if (child.props?.children) { - const grandchildren = React.Children.toArray(child.props.children) - if (grandchildren.some((gc: React.ReactNode) => - React.isValidElement(gc) && - (gc.type === 'img' || gc.type === Image || gc.props?.['data-markdown-image']) - )) { - return
- } - } - } + // Check all children for block-level elements + if (childrenArray.some(isBlockLevel)) { + return
} return

@@ -422,12 +521,18 @@ export default function MarkdownArticle({ // Check if this is actually a video or audio URL (converted by remarkMedia) if (cleanedSrc && (isVideo(cleanedSrc) || isAudio(cleanedSrc))) { + // Wrap MediaPlayer in a div to ensure it's block-level and breaks out of paragraphs + // Use stable key to prevent flickering + const stableKey = cleanedSrc return ( - +

+ +
) } @@ -438,27 +543,33 @@ export default function MarkdownArticle({ // Always render images inline in their content position // The shared lightbox will show all images (content + tags) when clicked + // Wrap in div to ensure block-level rendering and prevent paragraph nesting + // Use stable key based on cleaned URL to prevent flickering + const stableKey = cleanedSrc || src return ( - = 0 ? imageIndex.toString() : undefined} - onClick={(e) => { - e.stopPropagation() - if (imageIndex >= 0) { - setLightboxIndex(imageIndex) - } - }} - /> +
+ = 0 ? imageIndex.toString() : undefined} + onClick={(e) => { + e.stopPropagation() + if (imageIndex >= 0) { + setLightboxIndex(imageIndex) + } + }} + /> +
) } }) as Components, - [showImageGallery, event.pubkey, event.kind, contentHashtags, allImages, navigateToHashtag] + [showImageGallery, event.pubkey, event.kind, contentHashtagsKey, allImagesKey, navigateToHashtag] ) return ( @@ -666,7 +777,7 @@ export default function MarkdownArticle({ /> ) })()} - + {processedContent} @@ -706,6 +817,26 @@ export default function MarkdownArticle({ ))}
)} + + {/* WebPreview cards for links from content (in order of appearance) */} + {contentLinks.length > 0 && ( +
+

Links

+ {contentLinks.map((url, index) => ( + + ))} +
+ )} + + {/* WebPreview cards for links from tags */} + {tagLinks.length > 0 && ( +
+

Related Links

+ {tagLinks.map((url, index) => ( + + ))} +
+ )}
{/* Image carousel lightbox - shows all images (content + tags), already cleaned and deduplicated */} diff --git a/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts b/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts index 7766618..beb7726 100644 --- a/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts +++ b/src/components/Note/MarkdownArticle/remarkUnwrapImages.ts @@ -1,4 +1,4 @@ -import type { Paragraph, Root, Image, Link } from 'mdast' +import type { Paragraph, Root, Image, Link, RootContent } from 'mdast' import type { Plugin } from 'unified' import { visit } from 'unist-util-visit' @@ -20,7 +20,7 @@ export const remarkUnwrapImages: Plugin<[], Root> = () => { if (children.length === 1 && children[0].type === 'image') { // Replace the paragraph with the image directly const image = children[0] as Image - parent.children.splice(index, 1, image) + parent.children.splice(index, 1, image as unknown as RootContent) return } @@ -29,14 +29,78 @@ export const remarkUnwrapImages: Plugin<[], Root> = () => { const link = children[0] as Link if (link.children.length === 1 && link.children[0].type === 'image') { // Keep the link but remove the paragraph wrapper - parent.children.splice(index, 1, link) + parent.children.splice(index, 1, link as unknown as RootContent) return } } - // Case 3: Paragraph contains text and an image (less common but should handle) - // We'll leave these as-is since they're mixed content - // The paragraph handler in the component will still try to convert them to divs + // Case 3: Paragraph contains images mixed with text + // Split the paragraph: extract images as separate block elements, keep text in paragraph + const imageIndices: number[] = [] + children.forEach((child, i) => { + if (child.type === 'image') { + imageIndices.push(i) + } else if (child.type === 'link' && child.children.some(c => c.type === 'image')) { + imageIndices.push(i) + } + }) + + if (imageIndices.length > 0) { + // We have images in the paragraph - need to split it + const newNodes: RootContent[] = [] + let lastIndex = 0 + + imageIndices.forEach((imgIndex) => { + // Add text before the image as a paragraph (if any) + if (imgIndex > lastIndex) { + const textBefore = children.slice(lastIndex, imgIndex) + if (textBefore.length > 0 && textBefore.some(c => c.type === 'text' && c.value.trim())) { + newNodes.push({ + type: 'paragraph', + children: textBefore + } as unknown as RootContent) + } + } + + // Add the image as a separate block element + const imageChild = children[imgIndex] + if (imageChild.type === 'image') { + newNodes.push(imageChild as unknown as RootContent) + } else if (imageChild.type === 'link') { + newNodes.push(imageChild as unknown as RootContent) + } + + lastIndex = imgIndex + 1 + }) + + // Add remaining text after the last image (if any) + if (lastIndex < children.length) { + const textAfter = children.slice(lastIndex) + if (textAfter.length > 0 && textAfter.some(c => c.type === 'text' && c.value.trim())) { + newNodes.push({ + type: 'paragraph', + children: textAfter + } as unknown as RootContent) + } + } + + // If we only had images and whitespace, just use the images + if (newNodes.length === 0) { + // All content was images, extract them + children.forEach(child => { + if (child.type === 'image') { + newNodes.push(child as unknown as RootContent) + } else if (child.type === 'link' && child.children.some(c => c.type === 'image')) { + newNodes.push(child as unknown as RootContent) + } + }) + } + + // Replace the paragraph with the split nodes + if (newNodes.length > 0) { + parent.children.splice(index, 1, ...newNodes) + } + } }) } } diff --git a/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts b/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts new file mode 100644 index 0000000..d28f0ad --- /dev/null +++ b/src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts @@ -0,0 +1,60 @@ +import type { Paragraph, Root, RootContent } from 'mdast' +import type { Plugin } from 'unified' +import { visit } from 'unist-util-visit' +import { NostrNode } from './types' + +/** + * Remark plugin to unwrap nostr nodes from paragraphs + * This prevents the DOM nesting warning where
(EmbeddedNote/EmbeddedMention) appears inside

+ * + * Markdown wraps standalone nostr references in paragraphs. This plugin unwraps them at the AST level + * so they render directly without a

wrapper. + */ +export const remarkUnwrapNostr: Plugin<[], Root> = () => { + return (tree) => { + visit(tree, 'paragraph', (node: Paragraph, index, parent) => { + if (!parent || typeof index !== 'number') return + + const children = node.children + + // Type guard to check if a node is a NostrNode + const isNostrNode = (node: any): node is NostrNode => { + return node && node.type === 'nostr' + } + + // Case 1: Paragraph contains only a nostr node + if (children.length === 1 && isNostrNode(children[0])) { + // Replace the paragraph with the nostr node directly + // Cast to RootContent since we're promoting it to block level + const nostrNode = children[0] as unknown as RootContent + parent.children.splice(index, 1, nostrNode) + return + } + + // Case 2: Paragraph contains text and a nostr node + // If the paragraph only contains whitespace and a nostr node, unwrap it + const hasOnlyNostrAndWhitespace = children.every(child => { + if (isNostrNode(child)) return true + if (child.type === 'text') { + return !child.value.trim() // Only whitespace + } + return false + }) + + if (hasOnlyNostrAndWhitespace) { + // Find the nostr node and unwrap it + const nostrNode = children.find(isNostrNode) + if (nostrNode) { + // Cast to RootContent since we're promoting it to block level + parent.children.splice(index, 1, nostrNode as unknown as RootContent) + return + } + } + + // Case 3: Paragraph contains mixed content (text + nostr node) + // We'll leave these as-is since they're mixed content + // The paragraph handler in the component will convert them to divs + }) + } +} + diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index f6d816d..e2c87e2 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -10,7 +10,6 @@ import { Event, kinds } from 'nostr-tools' import { useMemo, useState } from 'react' import AudioPlayer from '../AudioPlayer' import ClientTag from '../ClientTag' -import EnhancedContent from '../UniversalContent/EnhancedContent' import { FormattedTimestamp } from '../FormattedTimestamp' import Nip05 from '../Nip05' import NoteOptions from '../NoteOptions' @@ -148,7 +147,7 @@ export default function Note({ } else if (event.kind === ExtendedKind.POLL) { content = ( <> - + ) @@ -161,12 +160,12 @@ export default function Note({ } else if (event.kind === ExtendedKind.RELAY_REVIEW) { content = } else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - content = + content = } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { content = } else if (event.kind === kinds.ShortTextNote || event.kind === ExtendedKind.COMMENT || event.kind === ExtendedKind.VOICE_COMMENT) { - // Plain text notes use EnhancedContent for proper image/media rendering - content = + // Plain text notes use MarkdownArticle for proper markdown rendering + content = } else { // Use MarkdownArticle for all other kinds // Only 30023, 30041, 30817, and 30818 will show image gallery and article info diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 916726c..9eee6f2 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -11,7 +11,7 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import ClientTag from '../ClientTag' import Collapsible from '../Collapsible' -import { parseNostrContent, renderNostrContent } from '@/lib/nostr-parser.tsx' +import MarkdownArticle from '../Note/MarkdownArticle/MarkdownArticle' import { FormattedTimestamp } from '../FormattedTimestamp' import Nip05 from '../Nip05' import NoteOptions from '../NoteOptions' @@ -109,10 +109,7 @@ export default function ReplyNote({ /> )} {show ? ( - (() => { - const parsedContent = parseNostrContent(event.content, event) - return renderNostrContent(parsedContent, 'mt-2 prose prose-base prose-zinc max-w-none break-words dark:prose-invert w-full') - })() + ) : (