import React, { type JSX, ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy, useMemo, useState, } from 'react'; import { DOMNode, Element, Text as DOMText, HTMLReactParserOptions, attributesToProps, domToReact, } from 'html-react-parser'; import { MatrixClient } from 'matrix-js-sdk'; import classNames from 'classnames'; import { Box, Chip, config, Header, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds'; import { IntermediateRepresentation, Opts as LinkifyOpts, OptFn } from 'linkifyjs'; import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; import { getMxIdLocalPart, getCanonicalAliasRoomId, isRoomAlias, mxcUrlToHttp, } from '../utils/matrix'; import { getMemberDisplayName } from '../utils/room'; import { EMOJI_PATTERN, sanitizeForRegex, URL_NEG_LB } from '../utils/regex'; import { getHexcodeForEmoji, getShortcodeFor } from './emoji'; import { findAndReplace } from '../utils/findAndReplace'; import { parseMatrixToRoom, parseMatrixToRoomEvent, parseMatrixToUser, testMatrixTo, } from './matrix-to'; import { onEnterOrSpace } from '../utils/keyboard'; import { copyToClipboard, tryDecodeURIComponent } from '../utils/dom'; import { useTimeoutToggle } from '../hooks/useTimeoutToggle'; import { tokenize, tokenStyle } from '../utils/syntaxHighlight'; import { splitMathSegments } from '../utils/mathParse'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); // KaTeX (and its CSS) is heavy, so it is code-split behind this dynamic import // and is NOT part of the eager import graph — see src/app/components/math/KaTeX.tsx. const KaTeXMath = lazy(() => import('../components/math/KaTeX')); /** Languages handled by the custom TDS tokenizer. */ const TDS_TOKENIZER_LANGS = new Set([ 'js', 'javascript', 'ts', 'typescript', 'jsx', 'tsx', 'py', 'python', 'rs', 'rust', ]); /** * Renders a code string as an array of coloured elements using the * lightweight TDS tokenizer. Falls back to a plain text node when the * language is not in the supported set. */ function renderTokenizedCode(code: string, lang: string): React.ReactNode { const normalised = lang.toLowerCase().replace(/^language-/, ''); if (!TDS_TOKENIZER_LANGS.has(normalised)) return code; const tokens = tokenize(code, normalised); return tokens.map((tok, idx) => ( {tok.text} )); } /** * Renders LaTeX via the lazily-loaded KaTeX component. * * `suspenseFallback` is shown while the KaTeX chunk loads (the raw LaTeX text). * `errorFallback` is shown if rendering fails outright — for the spec * `data-mx-maths` path this is the element's original children (the spec * fallback content); for the plain-text `$…$` path it is the raw source. */ const renderMath = ( latex: string, displayMode: boolean, suspenseFallback: React.ReactNode, errorFallback: React.ReactNode, ): JSX.Element => ( {errorFallback}}> {suspenseFallback}}> ); const EMOJI_REG_G = new RegExp(`${URL_NEG_LB}(${EMOJI_PATTERN})`, 'g'); export const LINKIFY_OPTS: LinkifyOpts = { attributes: { target: '_blank', rel: 'noreferrer noopener', }, validate: { url: (value) => /^(https?|ftp|mailto|magnet):/.test(value), }, ignoreTags: ['span'], }; export const makeMentionCustomProps = ( handleMentionClick?: ReactEventHandler, content?: string, ): ComponentPropsWithoutRef<'a'> => ({ style: { cursor: 'pointer' }, target: '_blank', rel: 'noreferrer noopener', role: 'link', tabIndex: handleMentionClick ? 0 : -1, onKeyDown: handleMentionClick ? onEnterOrSpace(handleMentionClick) : undefined, onClick: handleMentionClick, children: content, }); export const renderMatrixMention = ( mx: MatrixClient, currentRoomId: string | undefined, href: string, customProps: ComponentPropsWithoutRef<'a'>, ) => { const userId = parseMatrixToUser(href); if (userId) { const currentRoom = mx.getRoom(currentRoomId); return ( {`@${ (currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId) }`} ); } const matrixToRoom = parseMatrixToRoom(href); if (matrixToRoom) { const { roomIdOrAlias, viaServers } = matrixToRoom; const mentionRoom = mx.getRoom( isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias, ); const fallbackContent = mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias; return ( {customProps.children ? customProps.children : fallbackContent} ); } const matrixToRoomEvent = parseMatrixToRoomEvent(href); if (matrixToRoomEvent) { const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent; const mentionRoom = mx.getRoom( isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias, ); return ( {customProps.children ? customProps.children : `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`} ); } return undefined; }; export const factoryRenderLinkifyWithMention = ( mentionRender: (href: string) => JSX.Element | undefined, ): OptFn<(ir: IntermediateRepresentation) => any> => { const render: OptFn<(ir: IntermediateRepresentation) => any> = ({ tagName, attributes, content, }) => { if (tagName === 'a' && testMatrixTo(tryDecodeURIComponent(attributes.href))) { const mention = mentionRender(tryDecodeURIComponent(attributes.href)); if (mention) return mention; } return {content}; }; return render; }; export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] => findAndReplace( text, EMOJI_REG_G, (match, pushIndex) => { const shortcode = getShortcodeFor(getHexcodeForEmoji(match[0])); return ( {match[0]} ); }, (txt) => txt, ); export const makeHighlightRegex = (highlights: string[]): RegExp | undefined => { const pattern = highlights.map(sanitizeForRegex).join('|'); if (!pattern) return undefined; return new RegExp(pattern, 'gi'); }; export const highlightText = ( regex: RegExp, data: (string | JSX.Element)[], ): (string | JSX.Element)[] => data.flatMap((text) => { if (typeof text !== 'string') return text; return findAndReplace( text, regex, (match, pushIndex) => ( {match[0]} ), (txt) => txt, ); }); /** * Recursively extracts and concatenates all text content from an array of ChildNode objects. * * @param {ChildNode[]} nodes - An array of ChildNode objects to extract text from. * @returns {string} The concatenated plain text content of all descendant text nodes. */ const extractTextFromChildren = (nodes: ChildNode[]): string => { let text = ''; nodes.forEach((node) => { if (node.type === 'text') { text += node.data; } else if (node instanceof Element && node.children) { text += extractTextFromChildren(node.children); } }); return text; }; export function CodeBlock({ children, opts, }: { children: ChildNode[]; opts: HTMLReactParserOptions; }) { const code = children[0]; const attribs = code instanceof Element && code.name === 'code' ? code.attribs : undefined; const languageClass = attribs?.class; const customLabel = attribs?.['data-label']; const language = languageClass && languageClass.startsWith('language-') ? languageClass.replace('language-', '') : languageClass; const LINE_LIMIT = 14; const largeCodeBlock = useMemo( () => extractTextFromChildren(children).split('\n').length > LINE_LIMIT, [children], ); const [expanded, setExpand] = useState(false); const [copied, setCopied] = useTimeoutToggle(); const handleCopy = () => { copyToClipboard(extractTextFromChildren(children)); setCopied(); }; const toggleExpand = () => { setExpand(!expanded); }; return (
{customLabel ?? language ?? 'Code'} } > {copied ? 'Copied' : 'Copy'} {largeCodeBlock && ( )}
{domToReact(children as unknown as DOMNode[], opts)}
{largeCodeBlock && !expanded && }
); } export const getReactCustomHtmlParser = ( mx: MatrixClient, roomId: string | undefined, params: { linkifyOpts: LinkifyOpts; highlightRegex?: RegExp; handleSpoilerClick?: ReactEventHandler; handleMentionClick?: ReactEventHandler; useAuthentication?: boolean; }, ): HTMLReactParserOptions => { const opts: HTMLReactParserOptions = { replace: (domNode) => { if (domNode instanceof Element && 'name' in domNode) { const { name, attribs, children, parent } = domNode; const props = attributesToProps(attribs); if (name === 'h1') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'h2') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'h3') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'h4') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'h5') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'h6') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'p') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'pre') { return {children}; } if (name === 'blockquote') { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'ul') { return (
    {domToReact(children as unknown as DOMNode[], opts)}
); } if (name === 'ol') { return (
    {domToReact(children as unknown as DOMNode[], opts)}
); } if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { const codeReact = domToReact(children as unknown as DOMNode[], opts); if (typeof codeReact === 'string') { let lang: string | undefined = typeof props.className === 'string' ? props.className : undefined; if (lang === 'language-rs') lang = 'language-rust'; else if (lang === 'language-js') lang = 'language-javascript'; else if (lang === 'language-ts') lang = 'language-typescript'; // Use lightweight TDS tokenizer for supported languages to render // coloured elements with inline TDS CSS variable styles. const normLang = (lang ?? '').toLowerCase().replace(/^language-/, ''); if (TDS_TOKENIZER_LANGS.has(normLang)) { return ( {renderTokenizedCode(codeReact, normLang)} ); } return ( {codeReact}}> {codeReact}}> {(ref) => ( {codeReact} )} ); } } else { return ( {domToReact(children as unknown as DOMNode[], opts)} ); } } if (name === 'a' && testMatrixTo(tryDecodeURIComponent(String(props.href)))) { const content = children.find((child) => !(child instanceof DOMText)) ? undefined : children.map((c) => (c instanceof DOMText ? c.data : '')).join(); const mention = renderMatrixMention( mx, roomId, tryDecodeURIComponent(String(props.href)), makeMentionCustomProps(params.handleMentionClick, content), ); if (mention) return mention; } if ((name === 'span' || name === 'div') && 'data-mx-maths' in props) { // Spec (CS-API §11.5): render the `data-mx-maths` LaTeX with KaTeX // (block for
, inline for ). On failure fall back to the // element's existing children, which the spec defines as the fallback // representation. const latex = String(props['data-mx-maths']); const displayMode = name === 'div'; const fallback = displayMode ? (
{domToReact(children as unknown as DOMNode[], opts)}
) : ( {domToReact(children as unknown as DOMNode[], opts)} ); return renderMath(latex, displayMode, latex, fallback); } if (name === 'span' && 'data-mx-spoiler' in props) { return ( { if (e.key === 'Enter' || e.key === ' ') params.handleSpoilerClick?.(e as any); }} onClick={params.handleSpoilerClick} className={css.Spoiler()} aria-label="Spoiler — click to reveal" aria-pressed style={{ cursor: 'pointer' }} > {domToReact(children as unknown as DOMNode[], opts)} ); } if (name === 'img') { const htmlSrc = mxcUrlToHttp(mx, String(props.src), params.useAuthentication); if (htmlSrc && String(props.src).startsWith('mxc://') === false) { return ( {props.alt || props.title || htmlSrc} ); } if (htmlSrc && 'data-mx-emoticon' in props) { const emoticonAlt = (typeof props.alt === 'string' && props.alt) || (typeof props.title === 'string' && props.title) || 'emoji'; return ( {emoticonAlt} ); } if (htmlSrc) return ; } } if (domNode instanceof DOMText) { const parentName = domNode.parent && 'name' in domNode.parent ? domNode.parent.name : undefined; const linkify = parentName !== 'code' && parentName !== 'a'; // Never parse `$…$`/`$$…$$` math inside
/ (verbatim regions).
        const mathAllowed = parentName !== 'code' && parentName !== 'pre';

        const renderTextChunk = (text: string): (string | JSX.Element)[] | JSX.Element => {
          let jsx = scaleSystemEmoji(text);
          if (params.highlightRegex) {
            jsx = highlightText(params.highlightRegex, jsx);
          }
          if (linkify) {
            return {jsx};
          }
          return jsx;
        };

        if (mathAllowed) {
          const segments = splitMathSegments(domNode.data);
          if (segments.some((segment) => segment.type !== 'text')) {
            return (
              <>
                {segments.map((segment, index) => {
                  if (segment.type === 'text') {
                    return (
                      {renderTextChunk(segment.value)}
                    );
                  }
                  const raw =
                    segment.type === 'block' ? `$$${segment.value}$$` : `$${segment.value}$`;
                  return (
                    
                      {renderMath(segment.value, segment.type === 'block', raw, raw)}
                    
                  );
                })}
              
            );
          }
        }

        return renderTextChunk(domNode.data);
      }
      return undefined;
    },
  };
  return opts;
};