diff --git a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx index 123ff98..7ea3e7a 100644 --- a/src/components/Note/AsciidocArticle/AsciidocArticle.tsx +++ b/src/components/Note/AsciidocArticle/AsciidocArticle.tsx @@ -703,26 +703,109 @@ export default function AsciidocArticle({ }) // Handle YouTube URLs and relay URLs in links - htmlString = htmlString.replace(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g, (match, href, linkText) => { + // Process all link matches first to determine which are standalone + const linkMatches: Array<{ match: string; href: string; linkText: string; index: number; isStandalone: boolean }> = [] + const linkRegex = /]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/g + let linkMatch + while ((linkMatch = linkRegex.exec(htmlString)) !== null) { + const match = linkMatch[0] + const href = linkMatch[1] + const linkText = linkMatch[2] + const index = linkMatch.index + + // Check if link is standalone (on its own line, not part of a sentence/list/quote) + let isStandalone = false + if (href.startsWith('http://') || href.startsWith('https://')) { + // Get context around the link + const beforeMatch = htmlString.substring(Math.max(0, index - 500), index) + const afterMatch = htmlString.substring(index + match.length, Math.min(htmlString.length, index + match.length + 500)) + + // Extract the parent paragraph/div content + const paragraphMatch = beforeMatch.match(/]*>([^<]*)$/) + const divMatch = beforeMatch.match(/]*>([^<]*)$/) + + // If link is in a paragraph, check if paragraph contains only the link + if (paragraphMatch) { + const paragraphEnd = afterMatch.match(/^([^<]*)<\/p>/) + const paragraphContent = paragraphMatch[1] + linkText + (paragraphEnd?.[1] || '') + const trimmedContent = paragraphContent.trim() + // If paragraph contains only the link (possibly with whitespace), it's standalone + if (trimmedContent === linkText.trim() || trimmedContent === '') { + // Check if it's in a list or blockquote by looking further back + const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) + if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/]*>/)) { + isStandalone = true + } + } + } + + // If link is in a div and the div contains only the link, it's standalone + if (!isStandalone && divMatch) { + const divEnd = afterMatch.match(/^([^<]*)<\/div>/) + const divContent = divMatch[1] + linkText + (divEnd?.[1] || '') + const trimmedContent = divContent.trim() + if (trimmedContent === linkText.trim() || trimmedContent === '') { + const contextBefore = htmlString.substring(Math.max(0, index - 1000), index) + if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/]*>/)) { + isStandalone = true + } + } + } + + // If link appears to be on its own line (surrounded by block-level tags or whitespace) + if (!isStandalone) { + const beforeTrimmed = beforeMatch.replace(/\s*$/, '') + const afterTrimmed = afterMatch.replace(/^\s*/, '') + if ( + (beforeTrimmed.endsWith('

') || beforeTrimmed.endsWith('') || beforeTrimmed.endsWith('') || afterTrimmed.startsWith('') || afterTrimmed.startsWith(']*>/) && !contextBefore.match(/]*>/)) { + isStandalone = true + } + } + } + } + + linkMatches.push({ match, href, linkText, index, isStandalone }) + } + + // Replace links in reverse order to preserve indices + for (let i = linkMatches.length - 1; i >= 0; i--) { + const { match, href, linkText, isStandalone } = linkMatches[i] + let replacement = match + // Check if the href is a YouTube URL if (isYouTubeUrl(href)) { const cleanedUrl = cleanUrl(href) - return `
` + replacement = `
` } // Check if the href is a relay URL - if (isWebsocketUrl(href)) { + else if (isWebsocketUrl(href)) { const relayPath = `/relays/${encodeURIComponent(href)}` - return `${linkText}` + replacement = `${linkText}` } - // For regular HTTP/HTTPS links, replace with WebPreview placeholder - if (href.startsWith('http://') || href.startsWith('https://')) { - const cleanedUrl = cleanUrl(href) - return `
` + // For regular HTTP/HTTPS links, check if standalone + else if (href.startsWith('http://') || href.startsWith('https://')) { + if (isStandalone) { + // Standalone link - render as WebPreview + const cleanedUrl = cleanUrl(href) + replacement = `
` + } else { + // Inline link - keep as regular link + const escapedLinkText = linkText.replace(/"/g, '"') + replacement = `${linkText}` + } } - // For other links (like relative links), keep as-is - const escapedLinkText = linkText.replace(/"/g, '"') - return match.replace(/ tags) // Create a new regex instance to avoid state issues diff --git a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx index 400a57c..9be30fd 100644 --- a/src/components/Note/MarkdownArticle/MarkdownArticle.tsx +++ b/src/components/Note/MarkdownArticle/MarkdownArticle.tsx @@ -313,10 +313,48 @@ function parseMarkdownContent( const linkText = match[1] const hasImage = /^!\[/.test(linkText.trim()) + // Check if link is standalone (on its own line, not part of a sentence/list/quote) + const isStandalone = (() => { + // Get the line containing this link + const lineStart = content.lastIndexOf('\n', start) + 1 + const lineEnd = content.indexOf('\n', end) + const lineEndIndex = lineEnd === -1 ? content.length : lineEnd + const line = content.substring(lineStart, lineEndIndex) + + // Check if the line is just whitespace + the link (possibly with trailing whitespace) + const lineTrimmed = line.trim() + const linkMatch = lineTrimmed.match(/^\[([^\]]+)\]\(([^)]+)\)$/) + if (linkMatch) { + // Link is on its own line - check if it's in a list or blockquote + // Check if previous line starts with list marker or blockquote + const prevLineStart = content.lastIndexOf('\n', lineStart - 1) + 1 + const prevLine = content.substring(prevLineStart, lineStart - 1).trim() + + // Not standalone if it's part of a list or blockquote + if (prevLine.match(/^[\*\-\+]\s/) || prevLine.match(/^\d+\.\s/) || prevLine.match(/^>\s/)) { + return false + } + + // Standalone if it's on its own line and not in a list/blockquote + return true + } + + // Not standalone if it's part of a sentence + return false + })() + + // Only render as WebPreview if it's a standalone HTTP/HTTPS link (not YouTube, not relay, not image link) + const url = match[2] + const shouldRenderAsWebPreview = isStandalone && + !hasImage && + !isYouTubeUrl(url) && + !isWebsocketUrl(url) && + (url.startsWith('http://') || url.startsWith('https://')) + linkPatterns.push({ index: start, end: end, - type: hasImage ? 'markdown-image-link' : 'markdown-link', + type: hasImage ? 'markdown-image-link' : (shouldRenderAsWebPreview ? 'markdown-link-standalone' : 'markdown-link'), data: { text: match[1], url: match[2] } }) } @@ -545,6 +583,10 @@ function parseMarkdownContent( if (patternType === 'hashtag' || patternType === 'wikilink' || patternType === 'footnote-ref' || patternType === 'relay-url') { return true } + // Standalone links are block-level, not inline + if (patternType === 'markdown-link-standalone') { + return false + } if (patternType === 'markdown-link' && patternData) { const { url } = patternData // Markdown links are inline only if they're not YouTube or WebPreview @@ -693,6 +735,14 @@ function parseMarkdownContent( ) } + } else if (pattern.type === 'markdown-link-standalone') { + const { url } = pattern.data + // Standalone links render as WebPreview (OpenGraph card) + parts.push( +
+ +
+ ) } else if (pattern.type === 'markdown-link') { const { text, url } = pattern.data // Markdown links should always be rendered as inline links, not block-level components diff --git a/src/components/TrendingNotes/index.tsx b/src/components/TrendingNotes/index.tsx index 8c6d380..5dd4108 100644 --- a/src/components/TrendingNotes/index.tsx +++ b/src/components/TrendingNotes/index.tsx @@ -42,7 +42,7 @@ export default function TrendingNotes() { const [nostrLoading, setNostrLoading] = useState(false) const [nostrError, setNostrError] = useState(null) const [showCount, setShowCount] = useState(SHOW_COUNT) - const [activeTab, setActiveTab] = useState('nostr') + const [activeTab, setActiveTab] = useState('relays') const [sortOrder, setSortOrder] = useState('most-popular') const [hashtagFilter] = useState('popular') const [selectedHashtag, setSelectedHashtag] = useState(null) @@ -51,8 +51,9 @@ export default function TrendingNotes() { const [cacheLoading, setCacheLoading] = useState(false) const bottomRef = useRef(null) const isFetchingNostrRef = useRef(false) + const hasUserClickedNostrTabRef = useRef(false) - // Load Nostr.band trending feed when tab is active + // Load Nostr.band trending feed only when user explicitly clicks the nostr tab useEffect(() => { const loadTrending = async () => { // Prevent concurrent fetches @@ -81,7 +82,8 @@ export default function TrendingNotes() { } } - if (activeTab === 'nostr' && nostrEvents.length === 0 && !nostrLoading && !nostrError && !isFetchingNostrRef.current) { + // Only fetch if user has explicitly clicked the nostr tab AND it's currently active + if (activeTab === 'nostr' && hasUserClickedNostrTabRef.current && nostrEvents.length === 0 && !nostrLoading && !nostrError && !isFetchingNostrRef.current) { loadTrending() } }, [activeTab, nostrEvents.length, nostrLoading, nostrError]) @@ -606,7 +608,10 @@ export default function TrendingNotes() { hashtags