diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 22a9d6d..91d05ed 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/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 MediaPlayer from '@/components/MediaPlayer' import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' 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 { Event, kinds } from 'nostr-tools' 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 { preprocessAsciidocMediaLinks } from '../MarkdownArticle/preprocessMarkup' 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({ event, @@ -29,6 +40,7 @@ export default function AsciidocArticle({ }) { const { push } = useSecondaryPage() const { navigateToHashtag } = useSmartHashtagNavigation() + const { navigateToRelay } = useSmartRelayNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) const contentRef = useRef(null) @@ -213,6 +225,31 @@ export default function AsciidocArticle({ }) }, [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() + 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 const [parsedHtml, setParsedHtml] = useState('') const [isLoading, setIsLoading] = useState(true) @@ -294,6 +331,28 @@ export default function AsciidocArticle({ return `` }) + // Handle relay URLs (wss:// or ws://) in links - convert to relay page links + htmlString = htmlString.replace(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g, (match, href, linkText) => { + // Check if the href is a relay URL + if (isWebsocketUrl(href)) { + const relayPath = `/relays/${encodeURIComponent(href)}` + return `${linkText}` + } + // For regular links, store original text for truncation in DOM manipulation + const escapedLinkText = linkText.replace(/"/g, '"') + return match.replace(/ 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 `${match}` + } + return match + }) + setParsedHtml(htmlString) } catch (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 return () => { reactRootsRef.current.forEach((root) => { @@ -461,7 +552,7 @@ export default function AsciidocArticle({ }) reactRootsRef.current.clear() } - }, [parsedHtml, isLoading, navigateToHashtag]) + }, [parsedHtml, isLoading, navigateToHashtag, navigateToRelay]) // Initialize syntax highlighting useEffect(() => { @@ -558,10 +649,12 @@ export default function AsciidocArticle({ color: #5eead4 !important; } .asciidoc-content img { + display: block; max-width: 400px; height: auto; border-radius: 0.5rem; cursor: zoom-in; + margin: 0.5rem 0; } .asciidoc-content a[href^="/notes?t="] { color: #16a34a !important; @@ -671,10 +764,10 @@ export default function AsciidocArticle({ /> )} - {/* Hashtags from metadata */} - {!hideImagesAndInfo && metadata.tags.length > 0 && ( + {/* Hashtags from metadata (only if not already in content) */} + {!hideImagesAndInfo && leftoverMetadataTags.length > 0 && (
- {metadata.tags.map((tag) => ( + {leftoverMetadataTags.map((tag) => (
)} - {/* WebPreview cards for links from content */} - {contentLinks.length > 0 && ( -
-

Links

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

Related Links

- {tagLinks.map((url, index) => ( - - ))} -
- )} + {/* WebPreview cards for links from tags (only if not already in content) */} + {/* Note: Links in content are already rendered as links in the AsciiDoc HTML above, so we don't show WebPreview for them */} + {leftoverTagLinks.length > 0 && ( +
+ {leftoverTagLinks.map((url, index) => ( + + ))} +
+ )}
{/* Image gallery lightbox */} diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index d542cd5..faf53dc 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/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 MediaPlayer from '@/components/MediaPlayer' import Wikilink from '@/components/UniversalContent/Wikilink' @@ -6,10 +6,10 @@ import WebPreview from '@/components/WebPreview' import { getLongFormArticleMetadataFromEvent } from '@/lib/event-metadata' import { toNoteList } from '@/lib/link' 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 { Event, kinds } from 'nostr-tools' -import { ExtendedKind } from '@/constants' +import { ExtendedKind, WS_URL_REGEX } from '@/constants' import React, { useMemo, useState, useCallback } from 'react' import { createPortal } from 'react-dom' 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 { 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 * Post-processes: * - nostr: links -> EmbeddedNote or EmbeddedMention * - #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( content: string, @@ -30,13 +42,15 @@ function parseMarkdownContent( imageIndexMap: Map openLightbox: (index: number) => void navigateToHashtag: (href: string) => void + navigateToRelay: (url: string) => void } -): React.ReactNode[] { - const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag } = options +): { nodes: React.ReactNode[]; hashtagsInContent: Set } { + const { eventPubkey, imageIndexMap, openLightbox, navigateToHashtag, navigateToRelay } = options const parts: React.ReactNode[] = [] + const hashtagsInContent = new Set() 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 }> = [] // Markdown images: ![](url) or ![alt](url) @@ -71,18 +85,41 @@ function parseMarkdownContent( } }) - // Nostr addresses (nostr:npub1..., nostr:note1..., etc.) - 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 nostrMatches = Array.from(content.matchAll(nostrRegex)) - nostrMatches.forEach(match => { + // Relay URLs (wss:// or ws://) - not in markdown links + const relayUrlMatches = Array.from(content.matchAll(WS_URL_REGEX)) + relayUrlMatches.forEach(match => { if (match.index !== undefined) { + const url = match[0] // Only add if not already covered by a markdown link/image const isInMarkdown = patterns.some(p => (p.type === 'markdown-link' || p.type === 'markdown-image') && match.index! >= p.index && 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({ index: match.index, 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 hashtagMatches = Array.from(content.matchAll(hashtagRegex)) hashtagMatches.forEach(match => { @@ -165,12 +202,12 @@ function parseMarkdownContent( const imageIndex = imageIndexMap.get(cleaned) if (isImage(cleaned)) { parts.push( -
+
{ @@ -195,17 +232,58 @@ function parseMarkdownContent( } } else if (pattern.type === 'markdown-link') { 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( + { + e.stopPropagation() + e.preventDefault() + navigateToRelay(relayPath) + }} + title={text.length > 200 ? text : undefined} + > + {displayText} + + ) + } else { + // Render as green link (will show WebPreview at bottom for HTTP/HTTPS) + parts.push( + e.stopPropagation()} + title={text.length > 200 ? text : undefined} + > + {displayText} + + ) + } + } else if (pattern.type === 'relay-url') { + const { url } = pattern.data + const relayPath = `/relays/${encodeURIComponent(url)}` + const displayText = truncateLinkText(url) parts.push( e.stopPropagation()} + 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={url.length > 200 ? url : undefined} > - {text} + {displayText} ) } else if (pattern.type === 'nostr') { @@ -229,15 +307,17 @@ function parseMarkdownContent( } } else if (pattern.type === 'hashtag') { const tag = pattern.data + const tagLower = tag.toLowerCase() + hashtagsInContent.add(tagLower) // Track hashtags rendered inline parts.push( { e.stopPropagation() e.preventDefault() - navigateToHashtag(`/notes?t=${tag.toLowerCase()}`) + navigateToHashtag(`/notes?t=${tagLower}`) }} > #{tag} @@ -272,10 +352,10 @@ function parseMarkdownContent( // If no patterns, just return the content as text if (parts.length === 0) { - return [{content}] + return { nodes: [{content}], hashtagsInContent } } - return parts + return { nodes: parts, hashtagsInContent } } export default function MarkdownArticle({ @@ -289,6 +369,7 @@ export default function MarkdownArticle({ }) { const { push } = useSecondaryPage() const { navigateToHashtag } = useSmartHashtagNavigation() + const { navigateToRelay } = useSmartRelayNavigation() const metadata = useMemo(() => getLongFormArticleMetadataFromEvent(event), [event]) // Extract all media from event @@ -459,20 +540,35 @@ export default function MarkdownArticle({ }) }, [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 const preprocessedContent = useMemo(() => { return preprocessMarkdownMediaLinks(event.content) }, [event.content]) // Parse markdown content with post-processing for nostr: links and hashtags - const parsedContent = useMemo(() => { + const { nodes: parsedContent, hashtagsInContent } = useMemo(() => { return parseMarkdownContent(preprocessedContent, { eventPubkey: event.pubkey, imageIndexMap, 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 ( <> @@ -563,10 +659,10 @@ export default function MarkdownArticle({ {parsedContent}
- {/* Hashtags from metadata */} - {metadata.tags.length > 0 && ( + {/* Hashtags from metadata (only if not already in content) */} + {leftoverMetadataTags.length > 0 && (
- {metadata.tags.map((tag) => ( + {leftoverMetadataTags.map((tag) => (
)} - {/* WebPreview cards for links from content */} - {contentLinks.length > 0 && ( -
-

Links

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

Related Links

- {tagLinks.map((url, index) => ( + {/* WebPreview cards for links from tags (only if not already in content) */} + {/* Note: Links in content are already rendered as green hyperlinks above, so we don't show WebPreview for them */} + {leftoverTagLinks.length > 0 && ( +
+ {leftoverTagLinks.map((url, index) => ( ))}