Browse Source

render hyperlink cards, when the link is not part of a sentence.

imwald
Silberengel 4 months ago
parent
commit
c41e968ff1
  1. 103
      src/components/Note/AsciidocArticle/AsciidocArticle.tsx
  2. 52
      src/components/Note/MarkdownArticle/MarkdownArticle.tsx
  3. 13
      src/components/TrendingNotes/index.tsx

103
src/components/Note/AsciidocArticle/AsciidocArticle.tsx

@ -703,26 +703,109 @@ export default function AsciidocArticle({
}) })
// Handle YouTube URLs and relay URLs in links // Handle YouTube URLs and relay URLs in links
htmlString = htmlString.replace(/<a[^>]*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 = /<a[^>]*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(/<p[^>]*>([^<]*)$/)
const divMatch = beforeMatch.match(/<div[^>]*>([^<]*)$/)
// 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(/<blockquote[^>]*>/)) {
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(/<blockquote[^>]*>/)) {
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('</p>') || beforeTrimmed.endsWith('</div>') || beforeTrimmed.endsWith('<br') || beforeTrimmed === '') &&
(afterTrimmed.startsWith('</p>') || afterTrimmed.startsWith('</div>') || afterTrimmed.startsWith('<p') || afterTrimmed.startsWith('<div') || afterTrimmed === '')
) {
const contextBefore = htmlString.substring(Math.max(0, index - 1000), index)
if (!contextBefore.match(/<[uo]l[^>]*>/) && !contextBefore.match(/<blockquote[^>]*>/)) {
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 // Check if the href is a YouTube URL
if (isYouTubeUrl(href)) { if (isYouTubeUrl(href)) {
const cleanedUrl = cleanUrl(href) const cleanedUrl = cleanUrl(href)
return `<div data-youtube-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="youtube-placeholder my-2"></div>` replacement = `<div data-youtube-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="youtube-placeholder my-2"></div>`
} }
// Check if the href is a relay URL // Check if the href is a relay URL
if (isWebsocketUrl(href)) { else if (isWebsocketUrl(href)) {
const relayPath = `/relays/${encodeURIComponent(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>` replacement = `<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 HTTP/HTTPS links, replace with WebPreview placeholder // For regular HTTP/HTTPS links, check if standalone
if (href.startsWith('http://') || href.startsWith('https://')) { else if (href.startsWith('http://') || href.startsWith('https://')) {
if (isStandalone) {
// Standalone link - render as WebPreview
const cleanedUrl = cleanUrl(href) const cleanedUrl = cleanUrl(href)
return `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>` replacement = `<div data-webpreview-url="${cleanedUrl.replace(/"/g, '&quot;')}" class="webpreview-placeholder my-2"></div>`
} else {
// Inline link - keep as regular link
const escapedLinkText = linkText.replace(/"/g, '&quot;')
replacement = `<a href="${href}" class="inline text-green-600 dark:text-green-400 hover:text-green-700 dark:hover:text-green-300 hover:underline break-words" target="_blank" rel="noopener noreferrer" data-original-text="${escapedLinkText}">${linkText}</a>`
} }
// For other links (like relative links), keep as-is }
// For other links (like relative links), keep as-is but add data attribute
else {
const escapedLinkText = linkText.replace(/"/g, '&quot;') const escapedLinkText = linkText.replace(/"/g, '&quot;')
return match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`) replacement = match.replace(/<a/, `<a data-original-text="${escapedLinkText}"`)
}) }
htmlString = htmlString.substring(0, linkMatches[i].index) + replacement + htmlString.substring(linkMatches[i].index + match.length)
}
// Handle YouTube URLs in plain text (not in <a> tags) // Handle YouTube URLs in plain text (not in <a> tags)
// Create a new regex instance to avoid state issues // Create a new regex instance to avoid state issues

52
src/components/Note/MarkdownArticle/MarkdownArticle.tsx

@ -313,10 +313,48 @@ function parseMarkdownContent(
const linkText = match[1] const linkText = match[1]
const hasImage = /^!\[/.test(linkText.trim()) 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({ linkPatterns.push({
index: start, index: start,
end: end, 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] } data: { text: match[1], url: match[2] }
}) })
} }
@ -545,6 +583,10 @@ function parseMarkdownContent(
if (patternType === 'hashtag' || patternType === 'wikilink' || patternType === 'footnote-ref' || patternType === 'relay-url') { if (patternType === 'hashtag' || patternType === 'wikilink' || patternType === 'footnote-ref' || patternType === 'relay-url') {
return true return true
} }
// Standalone links are block-level, not inline
if (patternType === 'markdown-link-standalone') {
return false
}
if (patternType === 'markdown-link' && patternData) { if (patternType === 'markdown-link' && patternData) {
const { url } = patternData const { url } = patternData
// Markdown links are inline only if they're not YouTube or WebPreview // Markdown links are inline only if they're not YouTube or WebPreview
@ -693,6 +735,14 @@ function parseMarkdownContent(
</a> </a>
) )
} }
} else if (pattern.type === 'markdown-link-standalone') {
const { url } = pattern.data
// Standalone links render as WebPreview (OpenGraph card)
parts.push(
<div key={`webpreview-${patternIdx}`} className="my-2">
<WebPreview url={url} className="w-full" />
</div>
)
} else if (pattern.type === 'markdown-link') { } else if (pattern.type === 'markdown-link') {
const { text, url } = pattern.data const { text, url } = pattern.data
// Markdown links should always be rendered as inline links, not block-level components // Markdown links should always be rendered as inline links, not block-level components

13
src/components/TrendingNotes/index.tsx

@ -42,7 +42,7 @@ export default function TrendingNotes() {
const [nostrLoading, setNostrLoading] = useState(false) const [nostrLoading, setNostrLoading] = useState(false)
const [nostrError, setNostrError] = useState<string | null>(null) const [nostrError, setNostrError] = useState<string | null>(null)
const [showCount, setShowCount] = useState(SHOW_COUNT) const [showCount, setShowCount] = useState(SHOW_COUNT)
const [activeTab, setActiveTab] = useState<TrendingTab>('nostr') const [activeTab, setActiveTab] = useState<TrendingTab>('relays')
const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular') const [sortOrder, setSortOrder] = useState<SortOrder>('most-popular')
const [hashtagFilter] = useState<HashtagFilter>('popular') const [hashtagFilter] = useState<HashtagFilter>('popular')
const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null) const [selectedHashtag, setSelectedHashtag] = useState<string | null>(null)
@ -51,8 +51,9 @@ export default function TrendingNotes() {
const [cacheLoading, setCacheLoading] = useState(false) const [cacheLoading, setCacheLoading] = useState(false)
const bottomRef = useRef<HTMLDivElement>(null) const bottomRef = useRef<HTMLDivElement>(null)
const isFetchingNostrRef = useRef(false) 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(() => { useEffect(() => {
const loadTrending = async () => { const loadTrending = async () => {
// Prevent concurrent fetches // 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() loadTrending()
} }
}, [activeTab, nostrEvents.length, nostrLoading, nostrError]) }, [activeTab, nostrEvents.length, nostrLoading, nostrError])
@ -606,7 +608,10 @@ export default function TrendingNotes() {
hashtags hashtags
</button> </button>
<button <button
onClick={() => setActiveTab('nostr')} onClick={() => {
hasUserClickedNostrTabRef.current = true
setActiveTab('nostr')
}}
className={`px-3 py-1 text-sm rounded-md transition-colors ${ className={`px-3 py-1 text-sm rounded-md transition-colors ${
activeTab === 'nostr' activeTab === 'nostr'
? 'bg-primary text-primary-foreground' ? 'bg-primary text-primary-foreground'

Loading…
Cancel
Save