Browse Source

fixed markup

imwald
Silberengel 4 months ago
parent
commit
55d5b9b176
  1. 69
      src/components/Content/index.tsx
  2. 35
      src/components/Embedded/EmbeddedNormalUrl.tsx
  3. 85
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  4. 263
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  5. 76
      src/components/Note/MarkdownArticle/remarkUnwrapImages.ts
  6. 60
      src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts
  7. 9
      src/components/Note/index.tsx
  8. 7
      src/components/ReplyNote/index.tsx
  9. 333
      src/components/UniversalContent/EnhancedContent.tsx
  10. 156
      src/components/UniversalContent/ParsedContent.tsx

69
src/components/Content/index.tsx

@ -28,6 +28,7 @@ import Emoji from '../Emoji' @@ -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({ @@ -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<string>()
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<string>()
// 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({ @@ -354,6 +403,26 @@ export default function Content({
}
return null
})}
{/* WebPreview cards for links from content (in order of appearance) */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
)
}

35
src/components/Embedded/EmbeddedNormalUrl.tsx

@ -1,25 +1,20 @@ @@ -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 (
<a
className="text-primary hover:underline"
href={cleanedUrl}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer"
>
{cleanedUrl}
</a>
)
}
// Show WebPreview for all regular URLs (including those with nostr identifiers)
return <WebPreview url={cleanedUrl} className="mt-2" />
// Render all URLs as green text links (like hashtags) - WebPreview cards shown at bottom
return (
<a
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline"
href={cleanedUrl}
target="_blank"
onClick={(e) => e.stopPropagation()}
rel="noreferrer noopener"
>
{children || cleanedUrl}
</a>
)
}

85
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -15,6 +15,8 @@ import Lightbox from 'yet-another-react-lightbox' @@ -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({ @@ -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({ @@ -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<string>()
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<string>()
// 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<TImetaInfo[]>(() => {
@ -482,6 +547,26 @@ export default function AsciidocArticle({ @@ -482,6 +547,26 @@ export default function AsciidocArticle({
</CollapsibleContent>
</Collapsible>
)}
{/* WebPreview cards for links from content (in order of appearance) */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</article>
)
}

263
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -9,7 +9,7 @@ import { useMediaExtraction } from '@/hooks' @@ -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' @@ -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({ @@ -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<string>()
// 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<string>()
// 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<string>()
@ -85,7 +150,14 @@ export default function MarkdownArticle({ @@ -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({ @@ -141,7 +213,11 @@ export default function MarkdownArticle({
const components = useMemo(
() =>
({
nostr: ({ rawText, bech32Id }) => <NostrNode rawText={rawText} bech32Id={bech32Id} />,
nostr: ({ rawText, bech32Id }) => (
<div data-nostr-node="true" className="my-2">
<NostrNode rawText={rawText} bech32Id={bech32Id} />
</div>
),
a: ({ href, children, ...props }) => {
if (!href) {
return <span {...props} className="break-words" />
@ -275,14 +351,23 @@ export default function MarkdownArticle({ @@ -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 <WebPreview url={cleanedHref} className="mt-2" />
if (isRegularUrl && !isImage(cleanedHref) && !isMedia(cleanedHref)) {
return (
<a
{...props}
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline"
>
{children}
</a>
)
}
return (
@ -298,50 +383,64 @@ export default function MarkdownArticle({ @@ -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 <p>
// Convert to <div> 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 <div {...props} className="break-words" />
}
// 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 <div {...props} className="break-words" />
}
}
// 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 <p>
if (child.type === 'div') {
return true
}
}
// 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 <div {...props} className="break-words" />
// 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
}
// 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 <div {...props} className="break-words" />
// 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 block-level elements
if (childrenArray.some(isBlockLevel)) {
return <div {...props} className="break-words" />
}
return <p {...props} className="break-words" />
@ -422,12 +521,18 @@ export default function MarkdownArticle({ @@ -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 (
<MediaPlayer
src={cleanedSrc}
className="max-w-[400px] my-2"
mustLoad={false}
/>
<div key={`media-wrapper-${stableKey}`} className="my-2">
<MediaPlayer
key={`media-${stableKey}`}
src={cleanedSrc}
className="max-w-[400px]"
mustLoad={false}
/>
</div>
)
}
@ -438,27 +543,33 @@ export default function MarkdownArticle({ @@ -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 (
<Image
image={{ url: src, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg my-2 cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg inline-block',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
data-markdown-image="true"
data-image-index={imageIndex >= 0 ? imageIndex.toString() : undefined}
onClick={(e) => {
e.stopPropagation()
if (imageIndex >= 0) {
setLightboxIndex(imageIndex)
}
}}
/>
<div key={`img-wrapper-${stableKey}`} className="my-2 inline-block" data-markdown-image-wrapper="true">
<Image
key={`img-${stableKey}`}
image={{ url: src, pubkey: event.pubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{
wrapper: 'rounded-lg inline-block',
errorPlaceholder: 'aspect-square h-[30vh]'
}}
data-markdown-image="true"
data-image-index={imageIndex >= 0 ? imageIndex.toString() : undefined}
onClick={(e) => {
e.stopPropagation()
if (imageIndex >= 0) {
setLightboxIndex(imageIndex)
}
}}
/>
</div>
)
}
}) 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({ @@ -666,7 +777,7 @@ export default function MarkdownArticle({
/>
)
})()}
<Markdown remarkPlugins={[remarkGfm, remarkMath, remarkUnwrapImages, remarkNostr, remarkHashtags]} components={components}>
<Markdown remarkPlugins={[remarkGfm, remarkMath, remarkUnwrapImages, remarkNostr, remarkUnwrapNostr, remarkHashtags]} components={components}>
{processedContent}
</Markdown>
@ -706,6 +817,26 @@ export default function MarkdownArticle({ @@ -706,6 +817,26 @@ export default function MarkdownArticle({
))}
</div>
)}
{/* WebPreview cards for links from content (in order of appearance) */}
{contentLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3>
{contentLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
{/* WebPreview cards for links from tags */}
{tagLinks.length > 0 && (
<div className="space-y-3 mt-6 pt-4 border-t">
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Related Links</h3>
{tagLinks.map((url, index) => (
<WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))}
</div>
)}
</div>
{/* Image carousel lightbox - shows all images (content + tags), already cleaned and deduplicated */}

76
src/components/Note/MarkdownArticle/remarkUnwrapImages.ts

@ -1,4 +1,4 @@ @@ -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> = () => { @@ -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> = () => { @@ -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)
}
}
})
}
}

60
src/components/Note/MarkdownArticle/remarkUnwrapNostr.ts

@ -0,0 +1,60 @@ @@ -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 <div> (EmbeddedNote/EmbeddedMention) appears inside <p>
*
* Markdown wraps standalone nostr references in paragraphs. This plugin unwraps them at the AST level
* so they render directly without a <p> 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
})
}
}

9
src/components/Note/index.tsx

@ -10,7 +10,6 @@ import { Event, kinds } from 'nostr-tools' @@ -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({ @@ -148,7 +147,7 @@ export default function Note({
} else if (event.kind === ExtendedKind.POLL) {
content = (
<>
<EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
<Poll className="mt-2" event={event} />
</>
)
@ -161,12 +160,12 @@ export default function Note({ @@ -161,12 +160,12 @@ export default function Note({
} else if (event.kind === ExtendedKind.RELAY_REVIEW) {
content = <RelayReview className="mt-2" event={event} />
} else if (event.kind === ExtendedKind.PUBLIC_MESSAGE) {
content = <EnhancedContent className="mt-2" event={event} useEnhancedParsing={true} />
content = <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = <Zap className="mt-2" event={event} />
} 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 = <EnhancedContent className="mt-2" event={event} mustLoadMedia={false} />
// Plain text notes use MarkdownArticle for proper markdown rendering
content = <MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
} else {
// Use MarkdownArticle for all other kinds
// Only 30023, 30041, 30817, and 30818 will show image gallery and article info

7
src/components/ReplyNote/index.tsx

@ -11,7 +11,7 @@ import { useMemo, useState } from 'react' @@ -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({ @@ -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')
})()
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
) : (
<Button
variant="outline"

333
src/components/UniversalContent/EnhancedContent.tsx

@ -1,333 +0,0 @@ @@ -1,333 +0,0 @@
/**
* Enhanced content component that uses the content parser service
* while maintaining compatibility with existing embedded content
*/
import { useTranslatedEvent, useMediaExtraction } from '@/hooks'
import {
EmbeddedEmojiParser,
EmbeddedEventParser,
EmbeddedHashtagParser,
EmbeddedLNInvoiceParser,
EmbeddedMentionParser,
EmbeddedUrlParser,
EmbeddedWebsocketUrlParser,
parseContent
} from '@/lib/content-parser'
import logger from '@/lib/logger'
import { getEmojiInfosFromEmojiTags } from '@/lib/tag'
import { cn } from '@/lib/utils'
import { cleanUrl, isImage, isMedia, isAudio, isVideo } from '@/lib/url'
import { TImetaInfo } from '@/types'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import {
EmbeddedHashtag,
EmbeddedLNInvoice,
EmbeddedMention,
EmbeddedNormalUrl,
EmbeddedNote,
EmbeddedWebsocketUrl
} from '../Embedded'
import Emoji from '../Emoji'
import ImageGallery from '../ImageGallery'
import MediaPlayer from '../MediaPlayer'
import YoutubeEmbeddedPlayer from '../YoutubeEmbeddedPlayer'
import ParsedContent from './ParsedContent'
import { toNote } from '@/lib/link'
const REDIRECT_REGEX = /Read (naddr1[a-z0-9]+) instead\./i
function renderRedirectText(text: string, key: number) {
const match = text.match(REDIRECT_REGEX)
if (!match) {
return text
}
const [fullMatch, naddr] = match
const [prefix, suffix] = text.split(fullMatch)
const href = toNote(naddr)
return (
<span key={`redirect-${key}`}>
{prefix}
Read{' '}
<a
className="text-primary hover:underline"
href={href}
onClick={(e) => e.stopPropagation()}
>
{naddr}
</a>{' '}
instead.{suffix}
</span>
)
}
export default function EnhancedContent({
event,
content,
className,
mustLoadMedia,
useEnhancedParsing = false
}: {
event?: Event
content?: string
className?: string
mustLoadMedia?: boolean
useEnhancedParsing?: boolean
}) {
const translatedEvent = useTranslatedEvent(event?.id)
const _content = translatedEvent?.content ?? event?.content ?? content
// Use unified media extraction service
const extractedMedia = useMediaExtraction(event, _content)
// If enhanced parsing is enabled and we have an event, use the new parser
if (useEnhancedParsing && event) {
return (
<ParsedContent
event={event}
field="content"
className={className}
showMedia={true}
showLinks={false}
showHashtags={false}
showNostrLinks={false}
/>
)
}
// Fallback to original parsing logic
const { nodes, emojiInfos } = useMemo(() => {
if (!_content) return {}
const nodes = parseContent(_content, [
EmbeddedUrlParser,
EmbeddedLNInvoiceParser,
EmbeddedWebsocketUrlParser,
EmbeddedEventParser,
EmbeddedMentionParser,
EmbeddedHashtagParser,
EmbeddedEmojiParser
])
const emojiInfos = getEmojiInfosFromEmojiTags(event?.tags)
return { nodes, emojiInfos }
}, [_content, event])
if (!nodes || nodes.length === 0) {
return null
}
// Create maps for quick lookup of images/media by cleaned URL
const imageMap = new Map<string, TImetaInfo>()
const mediaMap = new Map<string, TImetaInfo>()
extractedMedia.all.forEach((img: TImetaInfo) => {
if (img.m?.startsWith('image/')) {
imageMap.set(img.url, img)
} else if (img.m?.startsWith('video/') || img.m?.startsWith('audio/') || img.m === 'media/*') {
mediaMap.set(img.url, img)
} else if (isImage(img.url)) {
imageMap.set(img.url, img)
} else if (isMedia(img.url)) {
mediaMap.set(img.url, img)
}
})
logger.debug('[EnhancedContent] Parsed content:', {
nodeCount: nodes.length,
allMedia: extractedMedia.all.length,
images: extractedMedia.images.length,
videos: extractedMedia.videos.length,
audio: extractedMedia.audio.length,
imageMapSize: imageMap.size,
mediaMapSize: mediaMap.size,
nodes: nodes.map(n => ({ type: n.type, data: Array.isArray(n.data) ? n.data.length : n.data }))
})
// Track which images/media have been rendered individually to prevent duplicates
const renderedUrls = new Set<string>()
// First pass: find which images/media appear in the content (will be rendered in a single carousel)
const mediaInContent = new Set<string>()
const imagesInContent: TImetaInfo[] = [] // Collect actual image info for carousel
nodes.forEach((node) => {
if (node.type === 'image') {
const cleanedUrl = cleanUrl(node.data)
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (node.type === 'images') {
const urls = Array.isArray(node.data) ? node.data : [node.data]
urls.forEach(url => {
const cleaned = cleanUrl(url)
mediaInContent.add(cleaned)
const imageInfo = imageMap.get(cleaned) || { url: cleaned, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleaned)) {
imagesInContent.push(imageInfo)
}
})
} else if (node.type === 'media') {
mediaInContent.add(cleanUrl(node.data))
} else if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
if (isImage(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
if (!imagesInContent.find(img => img.url === cleanedUrl)) {
imagesInContent.push(imageInfo)
}
} else if (isMedia(cleanedUrl)) {
mediaInContent.add(cleanedUrl)
}
}
})
// Filter carousel: only show IMAGES that DON'T appear in content
// (videos and audio should never be in carousel - they're rendered individually)
// (images in content will be rendered in a single carousel, not individually)
const carouselImages = extractedMedia.images.filter((img: TImetaInfo) => {
// Only include images that don't appear in content
return !mediaInContent.has(img.url)
})
return (
<div className={cn('prose prose-base prose-zinc max-w-none text-wrap break-words whitespace-pre-wrap dark:prose-invert', className)}>
{/* Render images that appear in content in a single carousel at the top */}
{imagesInContent.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="content-images-gallery"
images={imagesInContent}
start={0}
end={imagesInContent.length}
mustLoad={mustLoadMedia}
/>
)}
{/* Render images/media that aren't in content in a single carousel */}
{carouselImages.length > 0 && (
<ImageGallery
className="mt-2 mb-4"
key="all-images-gallery"
images={carouselImages}
start={0}
end={carouselImages.length}
mustLoad={mustLoadMedia}
/>
)}
{nodes.map((node, index) => {
if (node.type === 'text') {
return renderRedirectText(node.data, index)
}
// Skip image nodes - they're rendered in the carousel at the top
if (node.type === 'image' || node.type === 'images') {
return null
}
// Render media individually in their content position (only once per URL)
if (node.type === 'media') {
const cleanedUrl = cleanUrl(node.data)
// Skip if already rendered
if (renderedUrls.has(cleanedUrl)) {
return null
}
renderedUrls.add(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
key={index}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
if (node.type === 'url') {
const cleanedUrl = cleanUrl(node.data)
// Check if it's an image, video, or audio that should be rendered inline
const isImageUrl = isImage(cleanedUrl)
const isVideoUrl = isVideo(cleanedUrl)
const isAudioUrl = isAudio(cleanedUrl)
// Skip if already rendered (regardless of type)
if (renderedUrls.has(cleanedUrl)) {
return null
}
// Check video/audio first - never put them in ImageGallery
if (isVideoUrl || isAudioUrl || mediaMap.has(cleanedUrl)) {
renderedUrls.add(cleanedUrl)
return (
<MediaPlayer
className="mt-2"
key={`url-media-${index}`}
src={cleanedUrl}
mustLoad={mustLoadMedia}
/>
)
}
// Skip image URLs - they're rendered in the carousel at the top if they're in content
// Only render if they're NOT in content (from r tags, etc.)
if (isImageUrl) {
// If it's in content, skip it (already in carousel)
if (mediaInContent.has(cleanedUrl)) {
return null
}
// Otherwise it's an image from r tags not in content, render it
renderedUrls.add(cleanedUrl)
const imageInfo = imageMap.get(cleanedUrl) || { url: cleanedUrl, pubkey: event?.pubkey }
return (
<ImageGallery
className="mt-2"
key={`url-img-${index}`}
images={[imageInfo]}
start={0}
end={1}
mustLoad={mustLoadMedia}
/>
)
}
// Regular URL, not an image or media - show WebPreview
return <EmbeddedNormalUrl url={node.data} key={index} />
}
if (node.type === 'invoice') {
return <EmbeddedLNInvoice invoice={node.data} key={index} className="mt-2" />
}
if (node.type === 'websocket-url') {
return <EmbeddedWebsocketUrl url={node.data} key={index} />
}
if (node.type === 'event') {
const id = node.data.split(':')[1]
return <EmbeddedNote key={index} noteId={id} className="mt-2" />
}
if (node.type === 'mention') {
return <EmbeddedMention key={index} userId={node.data.split(':')[1]} />
}
if (node.type === 'hashtag') {
return <EmbeddedHashtag hashtag={node.data} key={index} />
}
if (node.type === 'emoji') {
const shortcode = node.data.split(':')[1]
const emoji = emojiInfos.find((e) => e.shortcode === shortcode)
if (!emoji) return node.data
return <Emoji classNames={{ img: 'mb-1' }} emoji={emoji} key={index} />
}
if (node.type === 'youtube') {
return (
<YoutubeEmbeddedPlayer
key={index}
url={node.data}
className="mt-2"
mustLoad={mustLoadMedia}
/>
)
}
return null
})}
</div>
)
}

156
src/components/UniversalContent/ParsedContent.tsx

@ -1,156 +0,0 @@ @@ -1,156 +0,0 @@
/**
* Universal content component that uses the content parser service
* for all Nostr content fields
*/
import { useEventFieldParser } from '@/hooks/useContentParser'
import { Event } from 'nostr-tools'
import ImageWithLightbox from '../ImageWithLightbox'
import WebPreview from '../WebPreview'
import HighlightSourcePreview from './HighlightSourcePreview'
interface ParsedContentProps {
event: Event
field: 'content' | 'title' | 'summary' | 'description'
className?: string
enableMath?: boolean
enableSyntaxHighlighting?: boolean
showMedia?: boolean
showLinks?: boolean
showHashtags?: boolean
showNostrLinks?: boolean
showHighlightSources?: boolean
}
export default function ParsedContent({
event,
field,
className = '',
enableMath = true,
enableSyntaxHighlighting = true,
showMedia = true,
showLinks = false,
showHashtags = false,
showNostrLinks = false,
showHighlightSources = false,
}: ParsedContentProps) {
const { parsedContent, isLoading, error } = useEventFieldParser(event, field, {
enableMath,
enableSyntaxHighlighting
})
if (isLoading) {
return (
<div className={`animate-pulse ${className}`}>
<div className="h-4 bg-muted rounded w-3/4 mb-2"></div>
<div className="h-4 bg-muted rounded w-1/2"></div>
</div>
)
}
if (error) {
return (
<div className={`text-red-500 text-sm ${className}`}>
Error loading content: {error.message}
</div>
)
}
if (!parsedContent) {
return (
<div className={`text-muted-foreground text-sm ${className}`}>
No content available
</div>
)
}
return (
<div className={`${parsedContent.cssClasses} ${className}`}>
{/* Render AsciiDoc content (everything is now processed as AsciiDoc) */}
<div dangerouslySetInnerHTML={{ __html: parsedContent.html }} />
{/* Media thumbnails */}
{showMedia && parsedContent.media.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Images in this content:</h4>
<div className="grid grid-cols-8 sm:grid-cols-12 md:grid-cols-16 gap-1">
{parsedContent.media.map((media, index) => (
<div key={index} className="aspect-square">
<ImageWithLightbox
image={media}
className="w-full h-full object-cover rounded cursor-pointer hover:opacity-80 transition-opacity"
classNames={{
wrapper: 'w-full h-full'
}}
/>
</div>
))}
</div>
</div>
)}
{/* Links summary with OpenGraph previews */}
{showLinks && parsedContent.links.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Links in this content:</h4>
<div className="space-y-3">
{parsedContent.links.map((link, index) => (
<WebPreview
key={index}
url={link.url}
className="w-full"
/>
))}
</div>
</div>
)}
{/* Hashtags */}
{showHashtags && parsedContent.hashtags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2">
{parsedContent.hashtags.map((tag) => (
<div
key={tag}
title={tag}
className="flex items-center rounded-full px-3 bg-muted text-muted-foreground max-w-44 cursor-pointer hover:bg-accent hover:text-accent-foreground"
>
#<span className="truncate">{tag}</span>
</div>
))}
</div>
)}
{/* Nostr links summary */}
{showNostrLinks && parsedContent.nostrLinks.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-2">Nostr references:</h4>
<div className="space-y-1">
{parsedContent.nostrLinks.map((link, index) => (
<div key={index} className="text-sm">
<span className="font-mono text-blue-600">{link.type}:</span>{' '}
<span className="font-mono">{link.id}</span>
</div>
))}
</div>
</div>
)}
{/* Highlight sources */}
{showHighlightSources && parsedContent.highlightSources.length > 0 && (
<div className="mt-4 p-4 bg-muted rounded-lg">
<h4 className="text-sm font-semibold mb-3">Highlight sources:</h4>
<div className="space-y-3">
{parsedContent.highlightSources.map((source, index) => (
<HighlightSourcePreview
key={index}
source={source}
className="w-full"
/>
))}
</div>
</div>
)}
</div>
)
}
Loading…
Cancel
Save