import React, { useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import ReactMarkdown, { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import supersub from 'remark-supersub'; import rehypeSanitize, { defaultSchema } from 'rehype-sanitize'; import rehypeRaw from 'rehype-raw'; import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, Placement, FloatingPortal } from '@floating-ui/react'; import { getLinkMediaInfo, getHasThumbnail } from '../../lib/utils/media-utils'; import { isCatalogView } from '../../lib/utils/view-utils'; import useIsMobile from '../../hooks/use-is-mobile'; import CommentMedia from '../comment-media'; import styles from './markdown.module.css'; import { Link, useLocation, useParams } from 'react-router-dom'; import { canEmbed } from '../embed'; import { is5chanLink, transform5chanLinkToInternal, preprocess5chanPatterns } from '../../lib/utils/url-utils'; import usePostNumberStore from '../../stores/use-post-number-store'; import useSubplebbitsPagesStore from '@plebbit/plebbit-react-hooks/dist/stores/subplebbits-pages'; import { useComment } from '@plebbit/plebbit-react-hooks'; import ReplyQuotePreview from '../reply-quote-preview'; interface ContentLinkEmbedProps { children: any; href: string; linkMediaInfo: any; } interface ExtendedComponents extends Components { spoiler: React.ComponentType<{ children: React.ReactNode }>; } const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => { const { t } = useTranslation(); const isMobile = useIsMobile(); const [isOpen, setIsOpen] = useState(false); const [showMedia, setShowMedia] = useState(false); const placementRef = useRef('right'); const availableWidthRef = useRef(0); const { refs, floatingStyles, update, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, placement: placementRef.current, middleware: [ shift({ padding: 10 }), offset({ mainAxis: 5 }), size({ apply({ availableWidth, elements }) { availableWidthRef.current = availableWidth; if (availableWidth >= 250) { elements.floating.style.maxWidth = `${availableWidth - 12}px`; } else if (placementRef.current === 'right') { placementRef.current = 'left'; } }, }), ], whileElementsMounted: autoUpdate, }); const hover = useHover(context, { move: false }); const focus = useFocus(context); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([hover, focus, dismiss]); useEffect(() => { const handleResize = () => { const availableWidth = availableWidthRef.current; if (availableWidth >= 250) { placementRef.current = 'right'; } else { placementRef.current = 'left'; } update(); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [update]); return ( <> {children} {' '} [ setShowMedia(!showMedia)} ref={refs.setReference} {...getReferenceProps()}> {showMedia ? t('remove') : isMobile ? t('open') : t('embed')} ] {showMedia && ( <>
)} {getHasThumbnail(linkMediaInfo, href) && ( {isOpen && !isMobile && (
)}
)} ); }; const MAX_LENGTH_FOR_GFM = 10000; // remarkGfm lags with large content const blockquoteToGreentext = () => (tree: any) => { tree.children.forEach((node: any) => { if (node.type === 'blockquote') { node.children.forEach((child: any) => { if (child.type === 'paragraph' && child.children.length > 0) { const prefix = { type: 'text', value: '>', }; child.children.unshift(prefix); } }); node.type = 'div'; node.data = { hName: 'div', hProperties: { className: 'greentext', }, }; } }); }; const spoilerTransform = () => (tree: any) => { const visit = (node: any) => { if (node.tagName === 'spoiler') { node.tagName = 'span'; node.properties = node.properties || {}; node.properties.className = 'spoilertext'; } if (node.children) { node.children.forEach(visit); } }; if (tree.children) { tree.children.forEach(visit); } }; interface MarkdownProps { content: string; title?: string; postCid?: string; } const NUMBER_QUOTE_HREF_REGEX = /^#q-(\d+)$/; const NumberQuoteLink = ({ number, threadPostCid }: { number: number; threadPostCid?: string }) => { const cid = usePostNumberStore((state) => state.numberToCid[number]); const commentFromStore = useSubplebbitsPagesStore((state) => (cid ? state.comments[cid] : undefined)); const commentFromHook = useComment({ commentCid: cid, onlyIfCached: true }); const comment = commentFromHook?.number !== undefined ? commentFromHook : commentFromStore; const isOP = Boolean(threadPostCid && cid === threadPostCid); if (!comment) { return {`>>${number}`}; } return ; }; const renderAnchorLink = (children: React.ReactNode, href: string, threadPostCid?: string) => { if (!href) { return {children}; } const numberQuoteMatch = href.match(NUMBER_QUOTE_HREF_REGEX); if (numberQuoteMatch) { const number = parseInt(numberQuoteMatch[1], 10); return ( ); } // Check if this is a valid 5chan link that should be handled internally if (is5chanLink(href)) { const internalPath = transform5chanLinkToInternal(href); if (internalPath) { // Check if the link text should be replaced with the internal path let shouldReplaceText = false; if (typeof children === 'string') { shouldReplaceText = children === href || children.trim() === href.trim(); } else if (Array.isArray(children) && children.length === 1 && typeof children[0] === 'string') { shouldReplaceText = children[0] === href || children[0].trim() === href.trim(); } // For display purposes, remove leading slash from paths like "/biz" or board identifiers let displayText: React.ReactNode = children; if (shouldReplaceText && internalPath.match(/^\/[^/]+$/)) { displayText = internalPath.substring(1); // Remove leading slash } else if (shouldReplaceText) { displayText = internalPath; } return {displayText}; } else { console.warn('Failed to transform 5chan link to internal path:', href); return {children}; } } // Handle hash routes and internal patterns (including routes that start with /#/) // Support both old format (/p/...) for backward compatibility and new format (/{boardIdentifier}/...) if ( href.startsWith('#/') || href.startsWith('/#/') || href.startsWith('/p/') || href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) || href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) || href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) ) { return {children}; } // External links return ( {children} ); }; const Markdown = ({ content, title, postCid }: MarkdownProps) => { const remarkPlugins = useMemo(() => { const plugins: any[] = [[supersub]]; if (content && content.length <= MAX_LENGTH_FOR_GFM) { plugins.push([remarkGfm, { singleTilde: false }]); } plugins.push([blockquoteToGreentext]); plugins.push([spoilerTransform]); return plugins; }, [content]); const customSchema = useMemo( () => ({ ...defaultSchema, tagNames: [...(defaultSchema.tagNames || []), 'div', 'span', 'spoiler'], attributes: { ...defaultSchema.attributes, div: ['className'], span: ['className'], spoiler: [], }, }), [], ); const location = useLocation(); const params = useParams(); const isInCatalogView = isCatalogView(location.pathname, params); const rehypePlugins = useMemo(() => [[rehypeRaw as any], [rehypeSanitize, customSchema]] as any[], [customSchema]); // Preprocess content to convert plain text 5chan patterns to markdown links const processedContent = useMemo(() => preprocess5chanPatterns(content || ''), [content]); const components = useMemo( () => ({ p: ({ children }) =>

{children}

, h1: ({ children }) =>

{children}

, h2: ({ children }) =>

{children}

, h3: ({ children }) =>

{children}

, h4: ({ children }) =>

{children}

, h5: ({ children }) =>

{children}

, h6: ({ children }) =>

{children}

, img: ({ src, alt }) => { const displayText = src || alt || 'image'; return {displayText}; }, video: ({ src }) => {src}, iframe: ({ src }) => {src}, source: ({ src }) => {src}, spoiler: ({ children }) => {children}, a: ({ href, children }) => { if (href && !isInCatalogView) { try { const linkMediaInfo = getLinkMediaInfo(href); const embedUrl = href.startsWith('http') ? new URL(href) : null; if ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href)) { return ; } } catch (e) { console.debug('Invalid URL:', href); } return renderAnchorLink(children, href, postCid); } return renderAnchorLink(children, href || '', postCid); }, }) as ExtendedComponents, [isInCatalogView, postCid], ); return ( {isInCatalogView && title && ( {title} {content ? ': ' : ''} )} ); }; export default React.memo(Markdown);