/* eslint-disable jsx-a11y/alt-text */ import React, { ComponentPropsWithoutRef, ReactEventHandler, Suspense, lazy, useMemo, useState, } from 'react'; import { 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'; const ReactPrism = lazy(() => import('./react-prism/ReactPrism')); 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|http|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) => ( {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 languageClass = code instanceof Element && code.name === 'code' ? code.attribs.class : undefined; 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 (
{language ?? 'Code'} } > {copied ? 'Copied' : 'Copy'} {largeCodeBlock && ( )}
{domToReact(children, 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, opts)} ); } if (name === 'h2') { return ( {domToReact(children, opts)} ); } if (name === 'h3') { return ( {domToReact(children, opts)} ); } if (name === 'h4') { return ( {domToReact(children, opts)} ); } if (name === 'h5') { return ( {domToReact(children, opts)} ); } if (name === 'h6') { return ( {domToReact(children, opts)} ); } if (name === 'p') { return ( {domToReact(children, opts)} ); } if (name === 'pre') { return {children}; } if (name === 'blockquote') { return ( {domToReact(children, opts)} ); } if (name === 'ul') { return (
    {domToReact(children, opts)}
); } if (name === 'ol') { return (
    {domToReact(children, opts)}
); } if (name === 'code') { if (parent && 'name' in parent && parent.name === 'pre') { const codeReact = domToReact(children, opts); if (typeof codeReact === 'string') { let lang = props.className; if (lang === 'language-rs') lang = 'language-rust'; else if (lang === 'language-js') lang = 'language-javascript'; else if (lang === 'language-ts') lang = 'language-typescript'; return ( {codeReact}}> {codeReact}}> {(ref) => ( {codeReact} )} ); } } else { return ( {domToReact(children, opts)} ); } } if (name === 'a' && testMatrixTo(tryDecodeURIComponent(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(props.href), makeMentionCustomProps(params.handleMentionClick, content) ); if (mention) return mention; } if (name === 'span' && 'data-mx-spoiler' in props) { return ( {domToReact(children, opts)} ); } if (name === 'img') { const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication); if (htmlSrc && props.src.startsWith('mxc://') === false) { return ( {props.alt || props.title || htmlSrc} ); } if (htmlSrc && 'data-mx-emoticon' in props) { return ( ); } if (htmlSrc) return ; } } if (domNode instanceof DOMText) { const linkify = !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code') && !(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'a'); let jsx = scaleSystemEmoji(domNode.data); if (params.highlightRegex) { jsx = highlightText(params.highlightRegex, jsx); } if (linkify) { return {jsx}; } return jsx; } return undefined; }, }; return opts; };