10 changed files with 502 additions and 591 deletions
@ -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> |
||||
) |
||||
} |
||||
|
||||
@ -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
|
||||
}) |
||||
} |
||||
} |
||||
|
||||
@ -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> |
||||
) |
||||
} |
||||
@ -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…
Reference in new issue