import React, { useEffect, useMemo, useRef, useState } from 'react'; import type { Placement } from '@floating-ui/react'; import { useTranslation } from 'react-i18next'; import { useDismiss, useFloating, useFocus, useHover, useInteractions, offset, shift, size, autoUpdate, 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, isValidCrossboardPattern } from '../../lib/utils/url-utils'; import { CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX, type ExternalQuoteReference } from '../../lib/utils/external-quote-utils'; import { isUnavailableQuoteTarget } from '../../lib/utils/quote-link-utils'; import usePostNumberStore, { getCidForPostNumber } from '../../stores/use-post-number-store'; import useCommunitiesPagesStore from '@bitsocial/bitsocial-react-hooks/dist/stores/communities-pages'; import { useComment } from '@bitsocial/bitsocial-react-hooks'; import ReplyQuotePreview from '../reply-quote-preview'; import ExternalNumberQuoteLink from './external-number-quote-link'; import { createDiceRollMarkupRegex, createFortuneMarkupRegex, getMatchingFortuneEntry } from '../../lib/utils/post-options-utils'; const safeParseUrl = (href: string): URL | null => { try { return href.startsWith('http') ? new URL(href) : null; } catch { return null; } }; interface ContentLinkEmbedProps { children: any; href: string; linkMediaInfo: any; } const ContentLinkEmbed = ({ children, href, linkMediaInfo }: ContentLinkEmbedProps) => { const { t } = useTranslation(); const isMobile = useIsMobile(); const [isOpen, setIsOpen] = useState(false); const [showMedia, setShowMedia] = useState(false); const [placement, setPlacement] = useState('right'); const availableWidthRef = useRef(0); const { refs, floatingStyles, update, context } = useFloating({ open: isOpen, onOpenChange: setIsOpen, placement, 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 (placement === 'right') { setPlacement('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) { setPlacement('right'); } else { setPlacement('left'); } update(); }; window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [update]); return ( <> {children} {' '} [ { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setShowMedia(!showMedia); } }} onClick={() => setShowMedia(!showMedia)} ref={refs.setReference} {...getReferenceProps()} > {showMedia ? t('remove') : isMobile ? t('open') : t('embed')} ] {showMedia && ( <>
)} {getHasThumbnail(linkMediaInfo, href) && ( {isOpen && !isMobile && (
)}
)} ); }; const normalizeContent = (content: string): string => { if (!content) return ''; let normalized = content.replace(/\n \n/g, '\n\n'); normalized = normalized.replace(/\n{3,}/g, '\n\n'); return normalized; }; type Token = | { key: string; type: 'text'; value: string } | { key: string; type: 'url'; href: string } | { key: string; type: 'quoteLink'; number: number } | { key: string; type: 'crossBoardNumberQuoteLink'; reference: ExternalQuoteReference } | { key: string; type: 'crossBoardLink'; display: string; route: string } | { key: string; type: 'spoiler'; tokens: Token[] }; const SPOILER_REGEX = /\[[sS][pP][oO][iI][lL][eE][rR]\]([\s\S]*?)\[\/[sS][pP][oO][iI][lL][eE][rR]\]/; const CROSSBOARD_REGEX = />>>\/((?:[a-zA-Z0-9]{1,10}\/(?:[a-zA-Z0-9]{46})?|[a-zA-Z0-9\-.]+(?:\/[a-zA-Z0-9]{46})?))[.,:;!?]*/; const QUOTE_LINK_REGEX = /(?/\w])>>(\d+)(?![\d/])/; const URL_REGEX = /https?:\/\/[^\s<>[\]]+/; type QstBbcodeTag = 'b' | 'i' | 'red' | 'green' | 'blue'; const QST_BBCODE_OPEN_REGEX = /\[(b|i|red|green|blue)\]/g; const QST_BBCODE_COLORS = { red: '#C41E3A', green: '#00A550', blue: '#1d8dc4', } satisfies Record, string>; const QST_BBCODE_COLOR_STYLES = { red: { color: QST_BBCODE_COLORS.red }, green: { color: QST_BBCODE_COLORS.green }, blue: { color: QST_BBCODE_COLORS.blue }, } satisfies Record, React.CSSProperties>; const COMBINED_REGEX = new RegExp( `(${SPOILER_REGEX.source})|(${CROSSBOARD_NUMBER_QUOTE_TOKEN_REGEX.source})|(${CROSSBOARD_REGEX.source})|(${QUOTE_LINK_REGEX.source})|(${URL_REGEX.source})`, 'g', ); const makeTokenKey = (prefix: string, type: Token['type'], start: number, end: number): string => `${prefix}${type}:${start}:${end}`; const isGreentextLine = (line: string): boolean => { if (line === '>') return true; if (!/^>+[^>]/.test(line)) return false; return !/^>>\d/.test(line) && !line.startsWith('>>>/'); }; function normalizeInternalRouteHref(href: string): string { if (href.startsWith('/#/')) { return href.slice(2); } if (href.startsWith('#/')) { return href.slice(1); } return href; } function splitUrlTrailingText(rawHref: string): { href: string; trailingText: string } { let href = rawHref; let trailingText = ''; while (href) { const trailingPunctuationMatch = href.match(/[.,;:!?"']+$/); if (trailingPunctuationMatch) { trailingText = `${trailingPunctuationMatch[0]}${trailingText}`; href = href.slice(0, -trailingPunctuationMatch[0].length); continue; } if (href.endsWith(')')) { const openingParens = (href.match(/\(/g) || []).length; const closingParens = (href.match(/\)/g) || []).length; if (closingParens > openingParens) { trailingText = `)${trailingText}`; href = href.slice(0, -1); continue; } } break; } return { href, trailingText }; } function getCrossboardRoute(fullPattern: string): string | null { const pathPart = fullPattern.replace(/^>>>\//, '').replace(/[.,:;!?]+$/, ''); if (!isValidCrossboardPattern(`>>>/${pathPart}`)) { return null; } if (/^[a-zA-Z0-9]{1,10}\/$/.test(pathPart)) { return `/${pathPart.slice(0, -1)}`; } if (/^[a-zA-Z0-9]{1,10}\/[a-zA-Z0-9]{46}$/.test(pathPart)) { const [code, cid] = pathPart.split('/'); return `/${code}/thread/${cid}`; } if (/^[^/]+\/[a-zA-Z0-9]{46}$/.test(pathPart)) { const [address, cid] = pathPart.split('/'); return `/${address}/thread/${cid}`; } return `/${pathPart}`; } function tokenize(text: string, keyPrefix = ''): Token[] { const tokens: Token[] = []; let lastIndex = 0; const regex = new RegExp(COMBINED_REGEX.source, 'g'); let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { const fullMatch = match[0]; const matchStart = match.index; const matchEnd = regex.lastIndex; if (matchStart > lastIndex) { tokens.push({ key: makeTokenKey(keyPrefix, 'text', lastIndex, matchStart), type: 'text', value: text.slice(lastIndex, matchStart), }); } if (match[1] !== undefined) { const innerContent = match[2]; const key = makeTokenKey(keyPrefix, 'spoiler', matchStart, matchEnd); tokens.push({ key, type: 'spoiler', tokens: tokenize(innerContent, `${key}/`) }); } else if (match[3] !== undefined) { const boardIdentifier = match[4]; const number = parseInt(match[5], 10); if (boardIdentifier && !Number.isNaN(number)) { tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardNumberQuoteLink', matchStart, matchEnd), type: 'crossBoardNumberQuoteLink', reference: { boardIdentifier, kind: 'cross-board', number, raw: `>>>/${boardIdentifier}/${number}`, }, }); } else { tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch }); } } else if (match[6] !== undefined) { const pathPart = match[7]; const fullPattern = `>>>/${pathPart}`; const route = getCrossboardRoute(fullPattern); if (route) { const trailingText = fullMatch.startsWith(fullPattern) ? fullMatch.slice(fullPattern.length) : ''; const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd; tokens.push({ key: makeTokenKey(keyPrefix, 'crossBoardLink', matchStart, linkEnd), type: 'crossBoardLink', display: fullPattern, route }); if (trailingText) { tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText }); } } else { tokens.push({ key: makeTokenKey(keyPrefix, 'text', matchStart, matchEnd), type: 'text', value: fullMatch }); } } else if (match[8] !== undefined) { const number = parseInt(match[9], 10); tokens.push({ key: makeTokenKey(keyPrefix, 'quoteLink', matchStart, matchEnd), type: 'quoteLink', number }); } else if (match[10] !== undefined) { const { href, trailingText } = splitUrlTrailingText(fullMatch); const linkEnd = trailingText ? matchEnd - trailingText.length : matchEnd; tokens.push({ key: makeTokenKey(keyPrefix, 'url', matchStart, linkEnd), type: 'url', href }); if (trailingText) { tokens.push({ key: makeTokenKey(keyPrefix, 'text', linkEnd, matchEnd), type: 'text', value: trailingText }); } } lastIndex = regex.lastIndex; } if (lastIndex < text.length) { tokens.push({ key: makeTokenKey(keyPrefix, 'text', lastIndex, text.length), type: 'text', value: text.slice(lastIndex), }); } return tokens; } interface RenderContext { isInCatalogView: boolean; postCid?: string; communityAddress?: string; enableQstBbcode: boolean; } interface MarkdownProps { content: string; title?: string; postCid?: string; communityAddress?: string; } const NumberQuoteLink = ({ number, threadPostCid, communityAddress }: { number: number; threadPostCid?: string; communityAddress?: string }) => { const cid = usePostNumberStore((state) => getCidForPostNumber(state.numberToCid, communityAddress, number)); const threadPostNumber = usePostNumberStore((state) => (threadPostCid ? state.cidToNumber[threadPostCid] : undefined)); const commentFromStore = useCommunitiesPagesStore((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) || (threadPostNumber !== undefined && number === threadPostNumber)); if (isUnavailableQuoteTarget(comment)) { return ( ); } if (!cid && communityAddress) { return ( >${number}`, communityAddress, }} /> ); } return ; }; const AnchorLink = ({ href, text }: { href: string; text: string }) => { if (!href) { return {text}; } if (is5chanLink(href)) { const internalPath = transform5chanLinkToInternal(href); if (internalPath) { const internalRoute = normalizeInternalRouteHref(internalPath); let displayText: React.ReactNode = text; const isAutolinkedUrl = text.startsWith('http'); if (isAutolinkedUrl) { displayText = text; } else if (internalRoute.match(/^\/[^/]+$/)) { displayText = internalRoute.substring(1); } else { displayText = internalRoute; } return {displayText}; } else { console.warn('Failed to transform 5chan link to internal path:', href); return {text}; } } if ( href.startsWith('#/') || href.startsWith('/#/') || href.startsWith('/p/') || href.match(/^\/p\/[^/]+(\/c\/[^/]+)?$/) || href.match(/^\/[^/]+(\/thread\/[^/]+)?$/) || href.match(/^\/[^/]+\/(catalog|description|rules)(\/settings)?$/) ) { return {text}; } return ( {text} ); }; const TokenNode = ({ token, context }: { token: Token; context: RenderContext }) => { const { isInCatalogView, postCid, communityAddress } = context; switch (token.type) { case 'text': return context.enableQstBbcode ? : <>{token.value}; case 'url': { const href = token.href; const linkMediaInfo = getLinkMediaInfo(href); const embedUrl = safeParseUrl(href); if (!isInCatalogView && ((embedUrl && canEmbed(embedUrl)) || getHasThumbnail(linkMediaInfo, href))) { return ( {href} ); } return ; } case 'quoteLink': return ( ); case 'crossBoardNumberQuoteLink': return ( ); case 'crossBoardLink': return {token.display}; case 'spoiler': return ( ); } }; const TokenList = ({ tokens, context }: { tokens: Token[]; context: RenderContext }) => { return ( <> {tokens.map((token) => ( ))} ); }; const findMatchingQstBbcodeClose = (text: string, tag: QstBbcodeTag, searchStart: number): number => { const tagRegex = new RegExp(`\\[(/?)${tag}\\]`, 'g'); tagRegex.lastIndex = searchStart; let depth = 1; let match: RegExpExecArray | null; while ((match = tagRegex.exec(text)) !== null) { depth += match[1] ? -1 : 1; if (depth === 0) { return match.index; } } return -1; }; const renderQstBbcodeElement = (tag: QstBbcodeTag, key: string, children: React.ReactNode[]) => { if (tag === 'b') { return {children}; } if (tag === 'i') { return {children}; } return ( {children} ); }; const renderQstBbcodeText = (text: string, keyPrefix: string): React.ReactNode[] => { const elements: React.ReactNode[] = []; const regex = new RegExp(QST_BBCODE_OPEN_REGEX.source, 'g'); let lastIndex = 0; let match: RegExpExecArray | null; while ((match = regex.exec(text)) !== null) { const tag = match[1] as QstBbcodeTag; const matchStart = match.index; const contentStart = regex.lastIndex; const closeStart = findMatchingQstBbcodeClose(text, tag, contentStart); if (closeStart === -1) { continue; } if (matchStart > lastIndex) { elements.push({text.slice(lastIndex, matchStart)}); } const closeEnd = closeStart + tag.length + 3; const childKeyPrefix = `${keyPrefix}${tag}-${matchStart}-`; elements.push(renderQstBbcodeElement(tag, `${keyPrefix}${tag}-${matchStart}-${closeEnd}`, renderQstBbcodeText(text.slice(contentStart, closeStart), childKeyPrefix))); lastIndex = closeEnd; regex.lastIndex = closeEnd; } if (lastIndex < text.length) { elements.push({text.slice(lastIndex)}); } return elements; }; const QstBbcodeText = ({ text, tokenKey }: { text: string; tokenKey: string }) => <>{renderQstBbcodeText(text, `${tokenKey}/`)}; const Fortune = ({ color, text }: { color: string; text: string }) => (

Your fortune: {text}
); const DiceRoll = ({ text }: { text: string }) => ( {text}

); const renderLineContent = (line: string, context: RenderContext): React.ReactNode[] => { const elements: React.ReactNode[] = []; let lastIndex = 0; const fortuneMarkupRegex = createFortuneMarkupRegex(); const diceRollMarkupRegex = createDiceRollMarkupRegex(); while (lastIndex < line.length) { fortuneMarkupRegex.lastIndex = lastIndex; diceRollMarkupRegex.lastIndex = lastIndex; const fortuneMatch = fortuneMarkupRegex.exec(line); const diceMatch = diceRollMarkupRegex.exec(line); const nextMatch = fortuneMatch && diceMatch ? fortuneMatch.index <= diceMatch.index ? { type: 'fortune' as const, match: fortuneMatch } : { type: 'dice' as const, match: diceMatch } : fortuneMatch ? { type: 'fortune' as const, match: fortuneMatch } : diceMatch ? { type: 'dice' as const, match: diceMatch } : null; if (!nextMatch) { break; } const [fullMatch] = nextMatch.match; const matchStart = nextMatch.match.index; const matchEnd = matchStart + fullMatch.length; if (matchStart > lastIndex) { elements.push(); } if (nextMatch.type === 'dice') { elements.push(); } else { const [, color, text] = nextMatch.match; const fortune = getMatchingFortuneEntry(color, text); if (fortune) { elements.push(); } else { elements.push(); } } lastIndex = matchEnd; } if (lastIndex < line.length) { elements.push(); } return elements; }; const Markdown = ({ content, title, postCid, communityAddress }: MarkdownProps) => { const location = useLocation(); const params = useParams(); const isInCatalogView = isCatalogView(location.pathname, params); const enableQstBbcode = location.pathname.split('/').filter(Boolean)[0] === 'qst'; const rendered = useMemo(() => { const normalized = normalizeContent(content || ''); const lines = normalized.split('\n'); const elements: React.ReactNode[] = []; const context = { isInCatalogView, postCid, communityAddress, enableQstBbcode }; lines.forEach((line, lineIndex) => { if (lineIndex > 0) { elements.push(
); } if (line.length === 0) return; const isGreentext = isGreentextLine(line); const lineElements = renderLineContent(line, context); if (isGreentext) { elements.push( {lineElements} , ); } else { elements.push({lineElements}); } }); return elements; }, [content, isInCatalogView, postCid, communityAddress, enableQstBbcode]); return ( {isInCatalogView && title && ( {title} {content ? ': ' : ''} )} {rendered} ); }; export default React.memo(Markdown);