]*>([^<]*)$/) + const divMatch = beforeMatch.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('
]*>/) && !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