import { RssFeedItem as TRssFeedItem, isWebOnlyFauxRssItem } from '@/services/rss-feed.service' import WebPreview from '../WebPreview' import { FormattedTimestamp } from '../FormattedTimestamp' import { ExternalLink, Globe, Highlighter, Rss } from 'lucide-react' import { useState, useRef, useEffect, useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Button } from '@/components/ui/button' import { useNostr } from '@/providers/NostrProvider' import PostEditor from '@/components/PostEditor' import { HighlightData } from '@/components/PostEditor/HighlightEditor' import { cn } from '@/lib/utils' import MediaPlayer from '@/components/MediaPlayer' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useSmartRssArticleNavigation } from '@/PageManager' import { getStandardRssFeedProfile } from '@/lib/standard-rss-feed-url' import { useRssFeedDisplayPrefs } from '@/components/RssFeedList/RssFeedDisplayPrefsContext' import { isClawstrDotComHttpHref } from '@/lib/rss-article' /** * Convert HTML to plain text by extracting text content and cleaning up whitespace */ function htmlToPlainText(html: string): string { if (!html) return '' // Create a temporary DOM element to extract text content const temp = document.createElement('div') temp.innerHTML = html // Get text content and clean up whitespace let text = temp.textContent || temp.innerText || '' // Clean up multiple consecutive newlines and whitespace text = text .replace(/\n{3,}/g, '\n\n') // Replace 3+ newlines with 2 .replace(/[ \t]+/g, ' ') // Replace multiple spaces/tabs with single space .replace(/ \n/g, '\n') // Remove spaces before newlines .replace(/\n /g, '\n') // Remove spaces after newlines .trim() return text } export default function RssFeedItem({ item, className, layout = 'detail', expandBodyFully = false, sourceStrip }: { item: TRssFeedItem className?: string /** `list`: title row + actions (open full article in side panel). `detail`: full body (secondary panel). */ layout?: 'list' | 'detail' /** When `layout` is `detail`, show full article HTML without height cap or “Show more”. */ expandBodyFully?: boolean /** Optional RSS vs Web URL hint for feed rows (combined cards use their own strip). */ sourceStrip?: 'rss' | 'web' }) { const { t } = useTranslation() const { suppressClawstrLinks } = useRssFeedDisplayPrefs() const { pubkey, checkLogin } = useNostr() const { isSmallScreen } = useScreenSize() const { navigateToRssArticle } = useSmartRssArticleNavigation() const isWebFaux = isWebOnlyFauxRssItem(item) const isListLayout = layout === 'list' const showFullBody = layout === 'detail' const [selectedText, setSelectedText] = useState('') const [highlightText, setHighlightText] = useState('') // Text to use in highlight editor const [showHighlightButton, setShowHighlightButton] = useState(false) const [showHighlightDrawer, setShowHighlightDrawer] = useState(false) const [selectionPosition, setSelectionPosition] = useState<{ x: number; y: number } | null>(null) const [isPostEditorOpen, setIsPostEditorOpen] = useState(false) const [highlightData, setHighlightData] = useState(undefined) const contentRef = useRef(null) const selectionTimeoutRef = useRef() const isSelectingRef = useRef(false) const touchEndTimeoutRef = useRef() const lastSelectionChangeRef = useRef(0) const selectionStableTimeoutRef = useRef() // Handle text selection useEffect(() => { const handleSelection = (forceShow = false) => { const selection = window.getSelection() if (!selection || selection.rangeCount === 0) { setShowHighlightButton(false) setSelectedText('') setSelectionPosition(null) return } const range = selection.getRangeAt(0) // Check if selection is collapsed (no actual selection) if (selection.isCollapsed || range.collapsed) { setShowHighlightButton(false) setSelectedText('') setSelectionPosition(null) return } // Check if contentRef exists if (!contentRef.current) { setShowHighlightButton(false) setSelectedText('') setSelectionPosition(null) return } // Check if selection is within this item's content // Use a more reliable containment check const commonAncestor = range.commonAncestorContainer // Check if the common ancestor is within our content element // Handle both Element and Text nodes let isSelectionInContent = false if (contentRef.current) { // For Element nodes, use contains directly if (commonAncestor.nodeType === Node.ELEMENT_NODE) { isSelectionInContent = contentRef.current.contains(commonAncestor as Element) } else { // For Text nodes, check if the parent element is contained const parentElement = commonAncestor.parentElement if (parentElement) { isSelectionInContent = contentRef.current.contains(parentElement) } } // Also check if the range intersects with our content if (!isSelectionInContent) { try { const contentRect = contentRef.current.getBoundingClientRect() const rangeRect = range.getBoundingClientRect() // Check if ranges overlap isSelectionInContent = !( rangeRect.bottom < contentRect.top || rangeRect.top > contentRect.bottom || rangeRect.right < contentRect.left || rangeRect.left > contentRect.right ) } catch { // If getBoundingClientRect fails, fall back to false isSelectionInContent = false } } } if (!isSelectionInContent) { setShowHighlightButton(false) setSelectedText('') setSelectionPosition(null) return } const text = selection.toString().trim() if (text.length > 0) { setSelectedText(text) // On mobile, only show drawer after selection is complete (not while actively selecting) // On desktop, show floating button immediately if (isSmallScreen) { // On mobile, wait until user finishes selecting before showing drawer if (forceShow || !isSelectingRef.current) { setShowHighlightDrawer(true) setShowHighlightButton(false) } } else { // Get selection position for button placement const rect = range.getBoundingClientRect() setSelectionPosition({ x: rect.left + rect.width / 2, y: rect.top - 10 }) setShowHighlightButton(true) setShowHighlightDrawer(false) } } else { setShowHighlightButton(false) setShowHighlightDrawer(false) setSelectedText('') setSelectionPosition(null) } } const handleMouseUp = (e: MouseEvent) => { // Don't process if clicking on the highlight button itself if ((e.target as HTMLElement).closest('.highlight-button-container')) { return } // Delay to allow selection to complete if (selectionTimeoutRef.current) { clearTimeout(selectionTimeoutRef.current) } selectionTimeoutRef.current = setTimeout(() => handleSelection(true), 50) } const handleClick = (e: MouseEvent) => { // Hide button if clicking outside the selection area and not on the button itself const target = e.target as HTMLElement if (showHighlightButton && !target.closest('.highlight-button-container')) { // Check if there's still a valid selection const selection = window.getSelection() if (!selection || selection.isCollapsed || selection.rangeCount === 0) { setShowHighlightButton(false) setSelectedText('') setSelectionPosition(null) } } } // Handle touch events for mobile const handleTouchStart = () => { if (isSmallScreen) { isSelectingRef.current = true // Clear any pending drawer show if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } setShowHighlightDrawer(false) } } const handleTouchMove = () => { if (isSmallScreen) { isSelectingRef.current = true // Clear any pending drawer show while actively selecting if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } setShowHighlightDrawer(false) } } const handleTouchEnd = () => { if (isSmallScreen) { // Mark that touch selection has ended // Wait a bit for native selection UI to appear if (touchEndTimeoutRef.current) { clearTimeout(touchEndTimeoutRef.current) } touchEndTimeoutRef.current = setTimeout(() => { isSelectingRef.current = false // Now check if there's a selection and show drawer after stability period lastSelectionChangeRef.current = Date.now() // Wait for selection to be stable (no changes for 1600ms) if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } selectionStableTimeoutRef.current = setTimeout(() => { const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current // Only show if selection hasn't changed in the last 1600ms and we're not actively selecting if (timeSinceLastChange >= 1600 && !isSelectingRef.current) { handleSelection(true) } }, 1600) }, 600) // Wait 600ms for native selection UI } } // Also listen for selectionchange events which fire more reliably const handleSelectionChange = () => { if (isSmallScreen) { // On mobile, track when selection last changed lastSelectionChangeRef.current = Date.now() // If we're actively selecting (touch events), don't process yet if (isSelectingRef.current) { return } // Check if there's actually a selection const selection = window.getSelection() const hasSelection = selection && !selection.isCollapsed && selection.rangeCount > 0 && selection.toString().trim().length > 0 // If no selection, clear drawer immediately if (!hasSelection) { if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } setShowHighlightDrawer(false) return } // Clear any pending drawer shows and reset the timeout if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } // Wait for selection to be stable (no changes for 1600ms) before showing drawer selectionStableTimeoutRef.current = setTimeout(() => { const timeSinceLastChange = Date.now() - lastSelectionChangeRef.current // Only show if selection hasn't changed in the last 1600ms and we're not actively selecting if (timeSinceLastChange >= 1600 && !isSelectingRef.current) { handleSelection(true) } }, 1600) } else { // Desktop: shorter delay if (selectionTimeoutRef.current) { clearTimeout(selectionTimeoutRef.current) } selectionTimeoutRef.current = setTimeout(() => handleSelection(true), 50) } } document.addEventListener('mouseup', handleMouseUp) document.addEventListener('click', handleClick, true) // Use capture phase document.addEventListener('selectionchange', handleSelectionChange) // Add touch event listeners for mobile if (isSmallScreen && contentRef.current) { const contentElement = contentRef.current contentElement.addEventListener('touchstart', handleTouchStart, { passive: true }) contentElement.addEventListener('touchmove', handleTouchMove, { passive: true }) contentElement.addEventListener('touchend', handleTouchEnd, { passive: true }) return () => { document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('click', handleClick, true) document.removeEventListener('selectionchange', handleSelectionChange) contentElement.removeEventListener('touchstart', handleTouchStart) contentElement.removeEventListener('touchmove', handleTouchMove) contentElement.removeEventListener('touchend', handleTouchEnd) if (selectionTimeoutRef.current) { clearTimeout(selectionTimeoutRef.current) } if (touchEndTimeoutRef.current) { clearTimeout(touchEndTimeoutRef.current) } if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } } } return () => { document.removeEventListener('mouseup', handleMouseUp) document.removeEventListener('click', handleClick, true) document.removeEventListener('selectionchange', handleSelectionChange) if (selectionTimeoutRef.current) { clearTimeout(selectionTimeoutRef.current) } if (touchEndTimeoutRef.current) { clearTimeout(touchEndTimeoutRef.current) } if (selectionStableTimeoutRef.current) { clearTimeout(selectionStableTimeoutRef.current) } } }, [showHighlightButton, isSmallScreen]) const handleCreateHighlight = () => { const currentSelection = window.getSelection() const text = currentSelection?.toString().trim() || selectedText if (!text) { return } // Store the text to highlight setHighlightText(text) // Convert HTML description to plain text for context const plainTextContext = htmlToPlainText(item.description) if (!pubkey) { checkLogin(() => { // After login, create highlight data and open editor const data: HighlightData = { sourceType: 'url', sourceValue: item.link, context: plainTextContext } setHighlightData(data) setIsPostEditorOpen(true) // Clear selection window.getSelection()?.removeAllRanges() setShowHighlightButton(false) setSelectedText('') }) return } // Create highlight data const data: HighlightData = { sourceType: 'url', sourceValue: item.link, context: plainTextContext } // Open PostEditor in highlight mode setHighlightData(data) setIsPostEditorOpen(true) // Clear selection window.getSelection()?.removeAllRanges() setShowHighlightButton(false) setSelectedText('') } const standardFeedProfile = useMemo( () => (isWebFaux ? null : getStandardRssFeedProfile(item.feedUrl)), [item.feedUrl, isWebFaux] ) // Format feed source name from URL (known shapes get a translated label) const feedSourceName = useMemo(() => { if (isWebFaux) return '' if (standardFeedProfile) { return t(standardFeedProfile.labelKey, { defaultValue: standardFeedProfile.defaultLabel }) } try { const url = new URL(item.feedUrl) return url.hostname.replace(/^www\./, '') } catch { return item.feedTitle || 'RSS Feed' } }, [item.feedUrl, item.feedTitle, isWebFaux, standardFeedProfile, t]) // Clean and parse HTML description safely // Decode HTML entities and remove any XML artifacts that might have leaked through const descriptionHtml = useMemo(() => { let html = item.description || '' if (!html) return '' // Decode HTML entities (like < > & " etc.) // Use textarea element which automatically decodes HTML entities when setting innerHTML // This is the most reliable way to decode entities in the browser const decoder = document.createElement('textarea') decoder.innerHTML = html html = decoder.value // Remove any trailing XML/CDATA artifacts html = html .replace(/\]\]\s*>\s*$/g, '') // Remove trailing ]]> from CDATA .replace(/^\s*]*\?>/gi, '') // Remove XML declarations .replace(/<\!DOCTYPE[^>]*>/gi, '') // Remove DOCTYPE declarations .trim() // Basic sanitization: remove script tags and dangerous attributes // Remove script tags and their content (including nested tags) html = html.replace(/)<[^<]*)*<\/script>/gi, '') // Remove event handlers (onclick, onerror, etc.) html = html.replace(/\s*on\w+\s*=\s*["'][^"']*["']/gi, '') // Remove javascript: URLs in href and src attributes html = html.replace(/javascript:/gi, '') // Remove data: URLs that might contain javascript (basic protection) html = html.replace(/data:\s*text\/html/gi, '') if (suppressClawstrLinks && html) { const wrap = document.createElement('div') wrap.innerHTML = html wrap.querySelectorAll('a[href]').forEach((el) => { const h = el.getAttribute('href') || '' if (isClawstrDotComHttpHref(h)) el.remove() }) html = wrap.innerHTML } return html }, [item.description, suppressClawstrLinks]) // Format publication date const pubDateTimestamp = item.pubDate ? Math.floor(item.pubDate.getTime() / 1000) : null // Check if content exceeds 400px height (detail layout only) const [needsCollapse, setNeedsCollapse] = useState(false) const [longBodyExpanded, setLongBodyExpanded] = useState(() => expandBodyFully && layout === 'detail') useEffect(() => { if (expandBodyFully && layout === 'detail') { setLongBodyExpanded(true) } }, [expandBodyFully, layout]) useEffect(() => { if (isWebFaux) { setNeedsCollapse(false) return } if (expandBodyFully && showFullBody) { setNeedsCollapse(false) return } if (isListLayout || !contentRef.current || !descriptionHtml) { setNeedsCollapse(false) return } const checkHeight = () => { const element = contentRef.current if (!element) return // Temporarily remove max-height to measure full content height const hadMaxHeight = element.classList.contains('max-h-[400px]') if (hadMaxHeight) { element.classList.remove('max-h-[400px]') element.style.maxHeight = 'none' } // Force a reflow to get accurate measurements void element.offsetHeight // Measure the actual content height const fullHeight = element.scrollHeight // Restore original state if (hadMaxHeight) { element.classList.add('max-h-[400px]') element.style.maxHeight = '' } setNeedsCollapse(fullHeight > 400) } // Check height after content is rendered (multiple checks for dynamic content) const timeoutId1 = setTimeout(checkHeight, 100) const timeoutId2 = setTimeout(checkHeight, 500) // Use ResizeObserver to detect when content changes const resizeObserver = new ResizeObserver(() => { if (!longBodyExpanded && !expandBodyFully) { checkHeight() } }) if (contentRef.current) { resizeObserver.observe(contentRef.current) } return () => { clearTimeout(timeoutId1) clearTimeout(timeoutId2) resizeObserver.disconnect() } }, [descriptionHtml, longBodyExpanded, isListLayout, expandBodyFully, showFullBody, isWebFaux]) return (
{ const target = e.target as HTMLElement if ( target.closest('a') || target.closest('button') || target.closest('[role="dialog"]') || target.closest('.highlight-button-container') ) { return } navigateToRssArticle(item.link) } : undefined } > {sourceStrip ? (
{sourceStrip === 'rss' ? ( ) : ( )} {sourceStrip === 'rss' ? t('RSS feed item label') : t('Web URL item label')}
) : null} {/* Feed Header with Metadata */}
{/* Feed Image/Logo */} {item.feedImage && !isWebFaux && ( {item.feedTitle { // Hide image on error e.currentTarget.style.display = 'none' }} /> )} {/* Feed Info */}

{isWebFaux ? t('Web page') : item.feedTitle || feedSourceName}

{!isWebFaux && standardFeedProfile && item.feedTitle ? (

{feedSourceName} {standardFeedProfile.detail ? ` · ${standardFeedProfile.detail}` : ''}

) : null} {item.feedDescription && (

{item.feedDescription}

)}
{pubDateTimestamp && ( )}
{/* Title */} {/* List layout: body lives in the secondary panel */} {showFullBody ? ( <> {/* Media (Images) */} {!isWebFaux && item.media && item.media.length > 0 && (
{item.media .filter(m => m.type?.startsWith('image/') || !m.type || m.type === 'image') .map((media, index) => { const hasThumbnail = !!media.thumbnail const imageUrl = media.thumbnail || media.url return (
{item.title} { e.stopPropagation() // Open full image in new tab window.open(media.url, '_blank', 'noopener,noreferrer') }} onError={(e) => { // Hide image on error e.currentTarget.style.display = 'none' }} /> {media.credit && (
{t('Photo')}: {media.credit}
)}
) })}
)} {/* Audio/Video Enclosure */} {!isWebFaux && item.enclosure && (item.enclosure.type.startsWith('audio/') || item.enclosure.type.startsWith('video/')) && (
{item.enclosure.type.startsWith('audio/') ? t('Audio') : t('Video')} {item.enclosure.duration && ( ({item.enclosure.duration}) )}
)} {/* RSS HTML body or OpenGraph web preview for URL-only faux items */}
{isWebFaux ? (
e.stopPropagation()} >
) : (
{ e.stopPropagation() }} /> )} {/* Gradient overlay when collapsed */} {!isWebFaux && needsCollapse && !longBodyExpanded && !expandBodyFully && (
)} {!isWebFaux && showFullBody && needsCollapse && !expandBodyFully && (
)} {/* Highlight Button (Desktop) */} {!isSmallScreen && showHighlightButton && selectedText && selectionPosition && (
)} {/* Highlight Drawer (Mobile) */} {isSmallScreen && ( { setShowHighlightDrawer(open) if (!open) { // Clear selection when drawer closes window.getSelection()?.removeAllRanges() setSelectedText('') setShowHighlightButton(false) } }} > {t('Create Highlight')}
{t('Selected text')}:
"{selectedText}"
)}
) : null} {/* Post Editor for highlights */} { setIsPostEditorOpen(open) if (!open) { setHighlightData(undefined) setHighlightText('') } }} defaultContent={highlightText} initialHighlightData={highlightData} />
) }