Browse Source

bug-fixed markup rendering

imwald
Silberengel 4 months ago
parent
commit
a9a598af94
  1. 133
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 180
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx

133
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -1,11 +1,11 @@
import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image' import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import WebPreview from '@/components/WebPreview' import WebPreview from '@/components/WebPreview'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks' import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url' import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useMemo, useState, useCallback, useEffect, useRef } from 'react' import { useMemo, useState, useCallback, useEffect, useRef } from 'react'
@ -17,6 +17,17 @@ import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' import { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { WS_URL_REGEX } from '@/constants'
/**
* Truncate link display text to 200 characters, adding ellipsis if truncated
*/
function truncateLinkText(text: string, maxLength: number = 200): string {
if (text.length <= maxLength) {
return text
}
return text.substring(0, maxLength) + '...'
}
export default function AsciidocArticle({ export default function AsciidocArticle({
event, event,
@ -29,6 +40,7 @@ export default function AsciidocArticle({
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
const contentRef = useRef<HTMLDivElement>(null) const contentRef = useRef<HTMLDivElement>(null)
@ -213,6 +225,31 @@ export default function AsciidocArticle({
}) })
}, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo]) }, [tagMedia, mediaUrlsInContent, metadata.image, hideImagesAndInfo])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
// Extract hashtags from content (for deduplication with metadata tags)
const hashtagsInContent = useMemo(() => {
const tags = new Set<string>()
const hashtagRegex = /#([a-zA-Z0-9_]+)/g
let match
while ((match = hashtagRegex.exec(event.content)) !== null) {
tags.add(match[1].toLowerCase())
}
return tags
}, [event.content])
// Filter metadata tags to only show what's not already in content
const leftoverMetadataTags = useMemo(() => {
return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase()))
}, [metadata.tags, hashtagsInContent])
// Parse AsciiDoc content and post-process for nostr: links and hashtags // Parse AsciiDoc content and post-process for nostr: links and hashtags
const [parsedHtml, setParsedHtml] = useState<string>('') const [parsedHtml, setParsedHtml] = useState<string>('')
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
@ -294,6 +331,28 @@ export default function AsciidocArticle({
return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>` return `<span data-wikilink="${escaped}" class="wikilink-placeholder"></span>`
}) })
// Handle relay URLs (wss:// or ws://) in links - convert to relay page links
htmlString = htmlString.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g, (match, href, linkText) => {
// Check if the href is a relay URL
if (isWebsocketUrl(href)) {
const relayPath = `/relays/${encodeURIComponent(href)}`
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${href}" data-original-text="${linkText.replace(/"/g, '&quot;')}">${linkText}</a>`
}
// For regular links, store original text for truncation in DOM manipulation
const escapedLinkText = linkText.replace(/"/g, '&quot;')
return match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`)
})
// Handle relay URLs in plain text (not in <a> tags) - convert to relay page links
htmlString = htmlString.replace(WS_URL_REGEX, (match) => {
// Only replace if not already in a tag (basic check)
if (!match.includes('<') && !match.includes('>') && isWebsocketUrl(match)) {
const relayPath = `/relays/${encodeURIComponent(match)}`
return `<a href="${relayPath}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer" data-relay-url="${match}" data-original-text="${match.replace(/"/g, '&quot;')}">${match}</a>`
}
return match
})
setParsedHtml(htmlString) setParsedHtml(htmlString)
} catch (error) { } catch (error) {
logger.error('Failed to parse AsciiDoc', error as Error) logger.error('Failed to parse AsciiDoc', error as Error)
@ -454,6 +513,38 @@ export default function AsciidocArticle({
} }
}) })
// Handle all links - truncate display text and add click handlers for relay URLs
const allLinks = contentRef.current.querySelectorAll('a[href]')
allLinks.forEach((link) => {
const href = link.getAttribute('href')
if (!href) return
// Get current link text (this might be the full URL or custom text)
const linkText = link.textContent || ''
// Truncate link text if it's longer than 200 characters
if (linkText.length > 200) {
const truncatedText = truncateLinkText(linkText)
link.textContent = truncatedText
// Store full text as title for tooltip
if (!link.getAttribute('title')) {
link.setAttribute('title', linkText)
}
}
// Handle relay URL links - add click handlers to navigate to relay page
const relayUrl = link.getAttribute('data-relay-url')
if (relayUrl) {
const relayPath = `/relays/${encodeURIComponent(relayUrl)}`
link.setAttribute('href', relayPath)
link.addEventListener('click', (e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
})
}
})
// Cleanup function // Cleanup function
return () => { return () => {
reactRootsRef.current.forEach((root) => { reactRootsRef.current.forEach((root) => {
@ -461,7 +552,7 @@ export default function AsciidocArticle({
}) })
reactRootsRef.current.clear() reactRootsRef.current.clear()
} }
}, [parsedHtml, isLoading, navigateToHashtag]) }, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay])
// Initialize syntax highlighting // Initialize syntax highlighting
useEffect(() => { useEffect(() => {
@ -558,10 +649,12 @@ export default function AsciidocArticle({
color: #5eead4 !important; color: #5eead4 !important;
} }
.asciidoc-content img { .asciidoc-content img {
display: block;
max-width: 400px; max-width: 400px;
height: auto; height: auto;
border-radius: 0.5rem; border-radius: 0.5rem;
cursor: zoom-in; cursor: zoom-in;
margin: 0.5rem 0;
} }
.asciidoc-content a[href^="/notes?t="] { .asciidoc-content a[href^="/notes?t="] {
color: #16a34a !important; color: #16a34a !important;
@ -671,10 +764,10 @@ export default function AsciidocArticle({
/> />
)} )}
{/* Hashtags from metadata */} {/* Hashtags from metadata (only if not already in content) */}
{!hideImagesAndInfo && metadata.tags.length > 0 && ( {!hideImagesAndInfo && leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4"> <div className="flex gap-2 flex-wrap pb-2 mt-4">
{metadata.tags.map((tag) => ( {leftoverMetadataTags.map((tag) => (
<div <div
key={tag} key={tag}
title={tag} title={tag}
@ -690,25 +783,15 @@ export default function AsciidocArticle({
</div> </div>
)} )}
{/* WebPreview cards for links from content */} {/* WebPreview cards for links from tags (only if not already in content) */}
{contentLinks.length > 0 && ( {/* Note: Links in content are already rendered as links in the AsciiDoc HTML above, so we don't show WebPreview for them */}
<div className="space-y-3 mt-6 pt-4 border-t"> {leftoverTagLinks.length > 0 && (
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3> <div className="space-y-3 mt-6">
{contentLinks.map((url, index) => ( {leftoverTagLinks.map((url, index) => (
<WebPreview key={`content-${index}-${url}`} url={url} className="w-full" /> <WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))} ))}
</div> </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> </div>
{/* Image gallery lightbox */} {/* Image gallery lightbox */}

180
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -1,4 +1,4 @@
import { useSecondaryPage, useSmartHashtagNavigation } from '@/PageManager' import { useSecondaryPage, useSmartHashtagNavigation, useSmartRelayNavigation } from '@/PageManager'
import Image from '@/components/Image' import Image from '@/components/Image'
import MediaPlayer from '@/components/MediaPlayer' import MediaPlayer from '@/components/MediaPlayer'
import Wikilink from '@/components/UniversalContent/Wikilink' import Wikilink from '@/components/UniversalContent/Wikilink'
@ -6,10 +6,10 @@ import WebPreview from '@/components/WebPreview'
import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import { useMediaExtraction } from '@/hooks' import { useMediaExtraction } from '@/hooks'
import { cleanUrl, isImage, isMedia, isVideo, isAudio } from '@/lib/url' import { cleanUrl, isImage, isMedia, isVideo, isAudio, isWebsocketUrl } from '@/lib/url'
import { getImetaInfosFromEvent } from '@/lib/event' import { getImetaInfosFromEvent } from '@/lib/event'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { ExtendedKind } from '@/constants' import { ExtendedKind, WS_URL_REGEX } from '@/constants'
import React, { useMemo, useState, useCallback } from 'react' import React, { useMemo, useState, useCallback } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import Lightbox from 'yet-another-react-lightbox' import Lightbox from 'yet-another-react-lightbox'
@ -17,11 +17,23 @@ import Zoom from 'yet-another-react-lightbox/plugins/zoom'
import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded' import { EmbeddedNote, EmbeddedMention } from '@/components/Embedded'
import { preprocessMarkdownMediaLinks } from './preprocessMarkup' import { preprocessMarkdownMediaLinks } from './preprocessMarkup'
/**
* Truncate link display text to 200 characters, adding ellipsis if truncated
*/
function truncateLinkText(text: string, maxLength: number = 200): string {
if (text.length <= maxLength) {
return text
}
return text.substring(0, maxLength) + '...'
}
/** /**
* Parse markdown content and render with post-processing for nostr: links and hashtags * Parse markdown content and render with post-processing for nostr: links and hashtags
* Post-processes: * Post-processes:
* - nostr: links -> EmbeddedNote or EmbeddedMention * - nostr: links -> EmbeddedNote or EmbeddedMention
* - #hashtags -> green hyperlinks to /notes?t=hashtag * - #hashtags -> green hyperlinks to /notes?t=hashtag
* - wss:// and ws:// URLs -> hyperlinks to /relays/{url}
* Returns both rendered nodes and a set of hashtags found in content (for deduplication)
*/ */
function parseMarkdownContent( function parseMarkdownContent(
content: string, content: string,
@ -30,13 +42,15 @@ function parseMarkdownContent(
imageIndexMap: Map<string, number> imageIndexMap: Map<string, number>
openLightbox: (index: number) => void openLightbox: (index: number) => void
navigateToHashtag: (href: string) => void navigateToHashtag: (href: string) => void
navigateToRelay: (url: string) => void
} }
): React.ReactNode[] { ): { nodes: React.ReactNode[]; hashtagsInContent: Set<string> } {
const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag } = options const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options
const parts: React.ReactNode[] = [] const parts: React.ReactNode[] = []
const hashtagsInContent = new Set<string>()
let lastIndex = 0 let lastIndex = 0
// Find all patterns: markdown images, markdown links, nostr addresses, hashtags, wikilinks // Find all patterns: markdown images, markdown links, relay URLs, nostr addresses, hashtags, wikilinks
const patterns: Array<{ index: number; end: number; type: string; data: any }> = [] const patterns: Array<{ index: number; end: number; type: string; data: any }> = []
// Markdown images: ![](url) or ![alt](url) // Markdown images: ![](url) or ![alt](url)
@ -71,18 +85,41 @@ function parseMarkdownContent(
} }
}) })
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links // Relay URLs (wss:// or ws://) - not in markdown links
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX))
const nostrMatches = Array.from(content.matchAll(nostrRegex)) relayUrlMatches.forEach(match => {
nostrMatches.forEach(match => {
if (match.index !== undefined) { if (match.index !== undefined) {
const url = match[0]
// Only add if not already covered by a markdown link/image // Only add if not already covered by a markdown link/image
const isInMarkdown = patterns.some(p => const isInMarkdown = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image') && (p.type === 'markdown-link' || p.type === 'markdown-image') &&
match.index! >= p.index && match.index! >= p.index &&
match.index! < p.end match.index! < p.end
) )
if (!isInMarkdown) { // Only process valid websocket URLs
if (!isInMarkdown && isWebsocketUrl(url)) {
patterns.push({
index: match.index,
end: match.index + match[0].length,
type: 'relay-url',
data: { url }
})
}
}
})
// Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - not in markdown links or relay URLs
const nostrRegex = /nostr:(npub1[a-z0-9]{58}|nprofile1[a-z0-9]+|note1[a-z0-9]{58}|nevent1[a-z0-9]+|naddr1[a-z0-9]+)/g
const nostrMatches = Array.from(content.matchAll(nostrRegex))
nostrMatches.forEach(match => {
if (match.index !== undefined) {
// Only add if not already covered by a markdown link/image or relay URL
const isInOther = patterns.some(p =>
(p.type === 'markdown-link' || p.type === 'markdown-image' || p.type === 'relay-url') &&
match.index! >= p.index &&
match.index! < p.end
)
if (!isInOther) {
patterns.push({ patterns.push({
index: match.index, index: match.index,
end: match.index + match[0].length, end: match.index + match[0].length,
@ -93,7 +130,7 @@ function parseMarkdownContent(
} }
}) })
// Hashtags (#tag) - but not inside markdown links or nostr addresses // Hashtags (#tag) - but not inside markdown links, relay URLs, or nostr addresses
const hashtagRegex = /#([a-zA-Z0-9_]+)/g const hashtagRegex = /#([a-zA-Z0-9_]+)/g
const hashtagMatches = Array.from(content.matchAll(hashtagRegex)) const hashtagMatches = Array.from(content.matchAll(hashtagRegex))
hashtagMatches.forEach(match => { hashtagMatches.forEach(match => {
@ -165,12 +202,12 @@ function parseMarkdownContent(
const imageIndex = imageIndexMap.get(cleaned) const imageIndex = imageIndexMap.get(cleaned)
if (isImage(cleaned)) { if (isImage(cleaned)) {
parts.push( parts.push(
<div key={`img-${i}`} className="my-2 inline-block"> <div key={`img-${i}`} className="my-2 block">
<Image <Image
image={{ url, pubkey: eventPubkey }} image={{ url, pubkey: eventPubkey }}
className="max-w-[400px] rounded-lg cursor-zoom-in" className="max-w-[400px] rounded-lg cursor-zoom-in"
classNames={{ classNames={{
wrapper: 'rounded-lg inline-block', wrapper: 'rounded-lg block',
errorPlaceholder: 'aspect-square h-[30vh]' errorPlaceholder: 'aspect-square h-[30vh]'
}} }}
onClick={(e) => { onClick={(e) => {
@ -195,17 +232,58 @@ function parseMarkdownContent(
} }
} else if (pattern.type === 'markdown-link') { } else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data const { text, url } = pattern.data
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS) const displayText = truncateLinkText(text)
// Check if it's a relay URL - if so, link to relay page instead
if (isWebsocketUrl(url)) {
const relayPath = `/relays/${encodeURIComponent(url)}`
parts.push(
<a
key={`relay-${i}`}
href={relayPath}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
navigateToRelay(relayPath)
}}
title={text.length > 200 ? text : undefined}
>
{displayText}
</a>
)
} else {
// Render as green link (will show WebPreview at bottom for HTTP/HTTPS)
parts.push(
<a
key={`link-${i}`}
href={url}
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 break-words"
onClick={(e) => e.stopPropagation()}
title={text.length > 200 ? text : undefined}
>
{displayText}
</a>
)
}
} else if (pattern.type === 'relay-url') {
const { url } = pattern.data
const relayPath = `/relays/${encodeURIComponent(url)}`
const displayText = truncateLinkText(url)
parts.push( parts.push(
<a <a
key={`link-${i}`} key={`relay-${i}`}
href={url} href={relayPath}
target="_blank" className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words cursor-pointer"
rel="noreferrer noopener" onClick={(e) => {
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" e.stopPropagation()
onClick={(e) => e.stopPropagation()} e.preventDefault()
navigateToRelay(relayPath)
}}
title={url.length > 200 ? url : undefined}
> >
{text} {displayText}
</a> </a>
) )
} else if (pattern.type === 'nostr') { } else if (pattern.type === 'nostr') {
@ -229,15 +307,17 @@ function parseMarkdownContent(
} }
} else if (pattern.type === 'hashtag') { } else if (pattern.type === 'hashtag') {
const tag = pattern.data const tag = pattern.data
const tagLower = tag.toLowerCase()
hashtagsInContent.add(tagLower) // Track hashtags rendered inline
parts.push( parts.push(
<a <a
key={`hashtag-${i}`} key={`hashtag-${i}`}
href={`/notes?t=${tag.toLowerCase()}`} href={`/notes?t=${tagLower}`}
className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer" className="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline cursor-pointer"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
e.preventDefault() e.preventDefault()
navigateToHashtag(`/notes?t=${tag.toLowerCase()}`) navigateToHashtag(`/notes?t=${tagLower}`)
}} }}
> >
#{tag} #{tag}
@ -272,10 +352,10 @@ function parseMarkdownContent(
// If no patterns, just return the content as text // If no patterns, just return the content as text
if (parts.length === 0) { if (parts.length === 0) {
return [<span key="text-only">{content}</span>] return { nodes: [<span key="text-only">{content}</span>], hashtagsInContent }
} }
return parts return { nodes: parts, hashtagsInContent }
} }
export default function MarkdownArticle({ export default function MarkdownArticle({
@ -289,6 +369,7 @@ export default function MarkdownArticle({
}) { }) {
const { push } = useSecondaryPage() const { push } = useSecondaryPage()
const { navigateToHashtag } = useSmartHashtagNavigation() const { navigateToHashtag } = useSmartHashtagNavigation()
const { navigateToRelay } = useSmartRelayNavigation()
const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event])
// Extract all media from event // Extract all media from event
@ -459,20 +540,35 @@ export default function MarkdownArticle({
}) })
}, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata]) }, [tagMedia, mediaUrlsInContent, metadata.image, hideMetadata])
// Filter tag links to only show what's not in content (to avoid duplicate WebPreview cards)
const leftoverTagLinks = useMemo(() => {
const contentLinksSet = new Set(contentLinks.map(link => cleanUrl(link)).filter(Boolean))
return tagLinks.filter(link => {
const cleaned = cleanUrl(link)
return cleaned && !contentLinksSet.has(cleaned)
})
}, [tagLinks, contentLinks])
// Preprocess content to convert URLs to markdown syntax // Preprocess content to convert URLs to markdown syntax
const preprocessedContent = useMemo(() => { const preprocessedContent = useMemo(() => {
return preprocessMarkdownMediaLinks(event.content) return preprocessMarkdownMediaLinks(event.content)
}, [event.content]) }, [event.content])
// Parse markdown content with post-processing for nostr: links and hashtags // Parse markdown content with post-processing for nostr: links and hashtags
const parsedContent = useMemo(() => { const { nodes: parsedContent, hashtagsInContent } = useMemo(() => {
return parseMarkdownContent(preprocessedContent, { return parseMarkdownContent(preprocessedContent, {
eventPubkey: event.pubkey, eventPubkey: event.pubkey,
imageIndexMap, imageIndexMap,
openLightbox, openLightbox,
navigateToHashtag navigateToHashtag,
navigateToRelay
}) })
}, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag]) }, [preprocessedContent, event.pubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay])
// Filter metadata tags to only show what's not already in content
const leftoverMetadataTags = useMemo(() => {
return metadata.tags.filter(tag => !hashtagsInContent.has(tag.toLowerCase()))
}, [metadata.tags, hashtagsInContent])
return ( return (
<> <>
@ -563,10 +659,10 @@ export default function MarkdownArticle({
{parsedContent} {parsedContent}
</div> </div>
{/* Hashtags from metadata */} {/* Hashtags from metadata (only if not already in content) */}
{metadata.tags.length > 0 && ( {leftoverMetadataTags.length > 0 && (
<div className="flex gap-2 flex-wrap pb-2 mt-4"> <div className="flex gap-2 flex-wrap pb-2 mt-4">
{metadata.tags.map((tag) => ( {leftoverMetadataTags.map((tag) => (
<div <div
key={tag} key={tag}
title={tag} title={tag}
@ -582,21 +678,11 @@ export default function MarkdownArticle({
</div> </div>
)} )}
{/* WebPreview cards for links from content */} {/* WebPreview cards for links from tags (only if not already in content) */}
{contentLinks.length > 0 && ( {/* Note: Links in content are already rendered as green hyperlinks above, so we don't show WebPreview for them */}
<div className="space-y-3 mt-6 pt-4 border-t"> {leftoverTagLinks.length > 0 && (
<h3 className="text-sm font-semibold text-muted-foreground mb-3">Links</h3> <div className="space-y-3 mt-6">
{contentLinks.map((url, index) => ( {leftoverTagLinks.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" /> <WebPreview key={`tag-${index}-${url}`} url={url} className="w-full" />
))} ))}
</div> </div>

Loading…
Cancel
Save