import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Comment, useAccount } from '@bitsocialhq/bitsocial-react-hooks'; import { useFloating, offset, shift, size, autoUpdate, Placement } from '@floating-ui/react'; import { useDirectories } from '../../hooks/use-directories'; import { getBoardPath } from '../../lib/utils/route-utils'; import { formatQuoteNumber, getQuoteTargetAvailability, shouldShowFloatingQuotePreview } from '../../lib/utils/quote-link-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import styles from '../../views/post/post.module.css'; import { Post } from '../../views/post'; interface ReplyQuotePreviewProps { isBacklinkReply?: boolean; backlinkReply?: Comment; isQuotelinkReply?: boolean; quotelinkReply?: Comment; quotelinkNumber?: number; isQuotelinkUnavailable?: boolean; isOP?: boolean; showTrailingBreak?: boolean; } const handleQuoteHover = (cid: string, onElementOutOfView: () => void) => { const targetElements = document.querySelectorAll(`[data-cid="${cid}"]`); const isOpElement = (element: HTMLElement) => element.getAttribute('data-post-cid') === cid; const isInViewport = (element: HTMLElement) => { const bounding = element.getBoundingClientRect(); return ( bounding.top >= 0 && bounding.left >= 0 && bounding.bottom <= (window.innerHeight || document.documentElement.clientHeight) && bounding.right <= (window.innerWidth || document.documentElement.clientWidth) ); }; let anyInView = false; targetElements.forEach((element) => { const htmlElement = element as HTMLElement; if (isInViewport(htmlElement)) { // Never apply quote-hover highlight styles to OP cards. if (isOpElement(htmlElement)) { htmlElement.classList.remove('highlight', 'double-highlight'); anyInView = true; return; } const hasHighlight = Array.from(htmlElement.classList).some((className) => className.includes('highlight') && !className.includes('double-highlight')); if (hasHighlight) { htmlElement.classList.remove('highlight'); htmlElement.classList.add('double-highlight'); } else { htmlElement.classList.remove('double-highlight'); htmlElement.classList.add('highlight'); } anyInView = true; } else { // If not in view, remove both classes htmlElement.classList.remove('highlight', 'double-highlight'); } }); if (!anyInView) { onElementOutOfView(); } }; const scrollToThreadCardTop = (threadCid: string) => { const threadCard = document.querySelector(`[data-cid="${threadCid}"][data-post-cid="${threadCid}"]`); if (!threadCard) return false; threadCard.scrollIntoView({ behavior: 'auto', block: 'start' }); return true; }; const scrollToReplyOnPage = (cid: string) => { const el = document.querySelector(`[data-cid="${cid}"][data-post-cid]`); if (!el) return false; document.querySelectorAll('.scroll-highlight').forEach((prev) => prev.classList.remove('scroll-highlight')); el.scrollIntoView({ behavior: 'auto', block: 'center' }); el.classList.add('scroll-highlight'); return true; }; const DesktopQuotePreview = ({ backlinkReply, quotelinkReply, quotelinkNumber, isBacklinkReply, isQuotelinkReply, isQuotelinkUnavailable, isOP, showTrailingBreak = true, }: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const [placement, setPlacement] = useState('right'); const availableWidthRef = useRef(0); const directories = useDirectories(); const { refs, floatingStyles, update } = useFloating({ placement, middleware: [ shift({ padding: 10 }), offset({ mainAxis: placement === 'right' ? 8 : 4 }), size({ apply({ availableWidth, elements }) { availableWidthRef.current = availableWidth; if (availableWidth >= 250) { elements.floating.style.maxWidth = `${availableWidth - 12}px`; } else if (placement === 'right') { setPlacement('left'); } }, }), ], whileElementsMounted: autoUpdate, }); useEffect(() => { const handleResize = () => { const availableWidth = availableWidthRef.current; if (availableWidth >= 250) { setPlacement('right'); } else { setPlacement('left'); } update(); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [update]); const navigate = useNavigate(); const location = useLocation(); const isOnThreadPage = location.pathname.includes('/thread/'); const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => { e.preventDefault(); if (cid && subplebbitAddress) { const boardPath = getBoardPath(subplebbitAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { if (location.pathname === threadRoute) { scrollToThreadCardTop(cid); } else { navigate(threadRoute); } return; } if (isOnThreadPage && scrollToReplyOnPage(cid)) return; navigate(threadRoute); } }; const handleMouseOver = (cid: string | undefined) => { if (!cid) return; handleQuoteHover(cid, () => setOutOfViewCid(cid)); setHoveredCid(cid); }; const handleMouseLeave = (cid: string | null) => { if (cid) { const targetElements = document.querySelectorAll(`[data-cid="${cid}"]`); targetElements.forEach((element) => { element.classList.remove('highlight'); element.classList.remove('double-highlight'); }); } setHoveredCid(null); setOutOfViewCid(null); }; const backlinkBoardPath = backlinkReply?.subplebbitAddress ? getBoardPath(backlinkReply.subplebbitAddress, directories) : undefined; const backlinkRoute = backlinkReply?.cid ? (backlinkBoardPath ? `/${backlinkBoardPath}/thread/${backlinkReply.cid}` : `/thread/${backlinkReply.cid}`) : '#'; const replyBacklink = ( <> handleMouseOver(backlinkReply?.cid)} onMouseLeave={() => handleMouseLeave(backlinkReply?.cid)} onClick={(e) => handleClick(e, backlinkReply?.cid, backlinkReply?.subplebbitAddress)} > {'>>'} {backlinkReply?.number ?? '?'} {hoveredCid === backlinkReply?.cid && outOfViewCid === backlinkReply?.cid && createPortal(
, document.body, )} ); const account = useAccount(); const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; const resolvedQuotelinkCid = quotelinkReply?.cid; const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; const quoteTargetAvailability = getQuoteTargetAvailability(quotelinkReply); const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || quoteTargetAvailability === 'unavailable'); const quotelinkPendingResolution = !quotelinkUnavailable && quoteTargetAvailability === 'unresolved'; const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; const quotelinkBoardPath = quotelinkReply?.subplebbitAddress ? getBoardPath(quotelinkReply.subplebbitAddress, directories) : undefined; const quotelinkRoute = quotelinkReply?.cid ? (quotelinkBoardPath ? `/${quotelinkBoardPath}/thread/${quotelinkReply.cid}` : `/thread/${quotelinkReply.cid}`) : '#'; const shouldShowQuotelinkPreview = shouldShowFloatingQuotePreview({ hoveredCid, outOfViewCid, quoteCid: resolvedQuotelinkCid, isUnavailable: quotelinkUnavailable, }); const quotelinkLabel = ( <> {formatQuoteNumber(resolvedQuotelinkNumber)} {isOP && ' (OP)'} {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} ); const replyQuotelink = ( <> {quotelinkUnavailable ? ( {quotelinkLabel} ) : quotelinkPendingResolution ? ( {quotelinkLabel} ) : ( handleMouseOver(resolvedQuotelinkCid)} onMouseLeave={() => handleMouseLeave(resolvedQuotelinkCid)} onClick={(e) => handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)} > {quotelinkLabel} )} {showTrailingBreak &&
} {shouldShowQuotelinkPreview && createPortal(
, document.body, )} ); return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; const MobileQuotePreview = ({ backlinkReply, quotelinkReply, quotelinkNumber, isBacklinkReply, isQuotelinkReply, isQuotelinkUnavailable, isOP, showTrailingBreak = true, }: ReplyQuotePreviewProps) => { const [hoveredCid, setHoveredCid] = useState(null); const [outOfViewCid, setOutOfViewCid] = useState(null); const directories = useDirectories(); const { refs, floatingStyles, update } = useFloating({ placement: 'bottom', middleware: [shift({ padding: isBacklinkReply ? 5 : 10 })], whileElementsMounted: autoUpdate, }); useEffect(() => { // Create a stable function reference for proper cleanup const handleResize = () => update(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [update]); const navigate = useNavigate(); const location = useLocation(); const isOnThreadPage = location.pathname.includes('/thread/'); const handleClick = (e: React.MouseEvent, cid: string | undefined, subplebbitAddress: string | undefined, isOpQuote = false) => { e.preventDefault(); if (cid && subplebbitAddress) { const boardPath = getBoardPath(subplebbitAddress, directories); const threadRoute = `/${boardPath}/thread/${cid}`; if (isOpQuote) { if (location.pathname === threadRoute) { scrollToThreadCardTop(cid); } else { navigate(threadRoute); } return; } if (isOnThreadPage && scrollToReplyOnPage(cid)) return; navigate(threadRoute); } }; const handleMouseOver = (cid: string | undefined) => { if (!cid) return; handleQuoteHover(cid, () => setOutOfViewCid(cid)); setHoveredCid(cid); }; const handleMouseLeave = (cid: string | null) => { if (cid) { const targetElements = document.querySelectorAll(`[data-cid="${cid}"]`); targetElements.forEach((element) => { element.classList.remove('highlight'); element.classList.remove('double-highlight'); }); } setHoveredCid(null); setOutOfViewCid(null); }; const replyBacklink = ( <> handleMouseOver(backlinkReply?.cid)} onMouseLeave={() => handleMouseLeave(backlinkReply?.cid)} > {`>>${backlinkReply?.number ?? '?'}`} {backlinkReply?.number && (() => { const backlinkBoardPath = backlinkReply?.subplebbitAddress ? getBoardPath(backlinkReply.subplebbitAddress, directories) : undefined; const backlinkRoute = backlinkReply?.cid ? (backlinkBoardPath ? `/${backlinkBoardPath}/thread/${backlinkReply.cid}` : `/thread/${backlinkReply.cid}`) : '#'; return ( handleClick(e, backlinkReply?.cid, backlinkReply?.subplebbitAddress)}> {' '} # ); })()} {hoveredCid === backlinkReply?.cid && outOfViewCid === backlinkReply?.cid && createPortal(
, document.body, )} ); const account = useAccount(); const resolvedQuotelinkNumber = quotelinkReply?.number ?? quotelinkNumber; const resolvedQuotelinkCid = quotelinkReply?.cid; const resolvedQuotelinkSubplebbitAddress = quotelinkReply?.subplebbitAddress; const quoteTargetAvailability = getQuoteTargetAvailability(quotelinkReply); const quotelinkUnavailable = Boolean(isQuotelinkUnavailable || quoteTargetAvailability === 'unavailable'); const quotelinkPendingResolution = !quotelinkUnavailable && quoteTargetAvailability === 'unresolved'; const quotelinkClassName = quotelinkUnavailable ? `${styles.quoteLink} ${styles.quoteLinkUnavailable}` : styles.quoteLink; const shouldShowQuotelinkPreview = shouldShowFloatingQuotePreview({ hoveredCid, outOfViewCid, quoteCid: resolvedQuotelinkCid, isUnavailable: quotelinkUnavailable, }); const replyQuotelink = ( <> handleMouseOver(resolvedQuotelinkCid)} onMouseLeave={quotelinkUnavailable || quotelinkPendingResolution ? undefined : () => handleMouseLeave(resolvedQuotelinkCid)} > {formatQuoteNumber(resolvedQuotelinkNumber)} {isOP && ' (OP)'} {quotelinkReply?.author?.address === account?.author?.address && ' (You)'} {!quotelinkUnavailable && !quotelinkPendingResolution && resolvedQuotelinkNumber && (() => { const quotelinkBoardPath = resolvedQuotelinkSubplebbitAddress ? getBoardPath(resolvedQuotelinkSubplebbitAddress, directories) : undefined; const quotelinkRoute = resolvedQuotelinkCid ? quotelinkBoardPath ? `/${quotelinkBoardPath}/thread/${resolvedQuotelinkCid}` : `/thread/${resolvedQuotelinkCid}` : '#'; return ( handleClick(e, resolvedQuotelinkCid, resolvedQuotelinkSubplebbitAddress, !!isOP)}> {' '} # ); })()} {showTrailingBreak &&
} {shouldShowQuotelinkPreview && createPortal(
, document.body, )} ); return isBacklinkReply ? replyBacklink : isQuotelinkReply && replyQuotelink; }; const ReplyQuotePreview = ({ backlinkReply, quotelinkReply, quotelinkNumber, isBacklinkReply, isQuotelinkReply, isQuotelinkUnavailable, isOP, showTrailingBreak, }: ReplyQuotePreviewProps) => { const isMobile = useIsMobile(); return isMobile ? ( ) : ( ); }; export default ReplyQuotePreview;