mirror of
https://github.com/cinnyapp/cinny.git
synced 2025-11-11 17:50:29 +03:00
524 lines
15 KiB
TypeScript
524 lines
15 KiB
TypeScript
/* eslint-disable jsx-a11y/alt-text */
|
|
import React, {
|
|
ComponentPropsWithoutRef,
|
|
ReactEventHandler,
|
|
Suspense,
|
|
lazy,
|
|
useCallback,
|
|
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 { Icon, IconButton, Icons, Scroll, Text } 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';
|
|
|
|
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<HTMLElement>,
|
|
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 (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({ highlight: mx.getUserId() === userId })}
|
|
data-mention-id={userId}
|
|
>
|
|
{`@${
|
|
(currentRoom && getMemberDisplayName(currentRoom, userId)) ?? getMxIdLocalPart(userId)
|
|
}`}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({
|
|
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
|
|
})}
|
|
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
|
|
data-mention-via={viaServers?.join(',')}
|
|
>
|
|
{customProps.children ? customProps.children : fallbackContent}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
const matrixToRoomEvent = parseMatrixToRoomEvent(href);
|
|
if (matrixToRoomEvent) {
|
|
const { roomIdOrAlias, eventId, viaServers } = matrixToRoomEvent;
|
|
const mentionRoom = mx.getRoom(
|
|
isRoomAlias(roomIdOrAlias) ? getCanonicalAliasRoomId(mx, roomIdOrAlias) : roomIdOrAlias
|
|
);
|
|
|
|
return (
|
|
<a
|
|
href={href}
|
|
{...customProps}
|
|
className={css.Mention({
|
|
highlight: currentRoomId === (mentionRoom?.roomId ?? roomIdOrAlias),
|
|
})}
|
|
data-mention-id={mentionRoom?.roomId ?? roomIdOrAlias}
|
|
data-mention-event-id={eventId}
|
|
data-mention-via={viaServers?.join(',')}
|
|
>
|
|
{customProps.children
|
|
? customProps.children
|
|
: `Message: ${mentionRoom ? `#${mentionRoom.name}` : roomIdOrAlias}`}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
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 <a {...attributes}>{content}</a>;
|
|
};
|
|
return render;
|
|
};
|
|
|
|
export const scaleSystemEmoji = (text: string): (string | JSX.Element)[] =>
|
|
findAndReplace(
|
|
text,
|
|
EMOJI_REG_G,
|
|
(match, pushIndex) => (
|
|
<span key={`scaleSystemEmoji-${pushIndex}`} className={css.EmoticonBase}>
|
|
<span className={css.Emoticon()} title={getShortcodeFor(getHexcodeForEmoji(match[0]))}>
|
|
{match[0]}
|
|
</span>
|
|
</span>
|
|
),
|
|
(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) => (
|
|
<span key={`highlight-${pushIndex}`} className={css.highlightText}>
|
|
{match[0]}
|
|
</span>
|
|
),
|
|
(txt) => txt
|
|
);
|
|
});
|
|
|
|
type CodeBlockControlsProps = {
|
|
copied: boolean;
|
|
onCopy: () => void;
|
|
collapsible: boolean;
|
|
collapsed: boolean;
|
|
onToggle: () => void;
|
|
};
|
|
|
|
function CodeBlockControls({
|
|
copied,
|
|
onCopy,
|
|
collapsible,
|
|
collapsed,
|
|
onToggle,
|
|
}: CodeBlockControlsProps) {
|
|
return (
|
|
// Needs a better copy icon
|
|
<div className={css.CodeBlockControls}>
|
|
<IconButton
|
|
variant="Secondary"
|
|
size="300"
|
|
radii="300"
|
|
onClick={onCopy}
|
|
aria-label="Copy Code Block"
|
|
>
|
|
<Icon src={copied ? Icons.Check : Icons.File} size="50" />
|
|
</IconButton>
|
|
{collapsible && (
|
|
<IconButton
|
|
variant="Secondary"
|
|
size="300"
|
|
radii="300"
|
|
onClick={onToggle}
|
|
aria-expanded={!collapsed}
|
|
aria-controls="code-block-content"
|
|
aria-label={collapsed ? 'Show Full Code Block' : 'Show Code Block Preview'}
|
|
style={collapsed ? { visibility: 'visible' } : {}}
|
|
>
|
|
<Icon src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} size="50" />
|
|
</IconButton>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function CodeBlock(children: ChildNode[], opts: HTMLReactParserOptions) {
|
|
const LINE_LIMIT = 14;
|
|
|
|
/**
|
|
* 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 = useCallback((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;
|
|
}, []);
|
|
|
|
const [copied, setCopied] = useState(false);
|
|
const collapsible = useMemo(
|
|
() => extractTextFromChildren(children).split('\n').length > LINE_LIMIT,
|
|
[children, extractTextFromChildren]
|
|
);
|
|
const [collapsed, setCollapsed] = useState(collapsible);
|
|
|
|
const handleCopy = useCallback(() => {
|
|
copyToClipboard(extractTextFromChildren(children));
|
|
setCopied(true);
|
|
}, [children, extractTextFromChildren]);
|
|
|
|
const toggleCollapse = useCallback(() => {
|
|
setCollapsed((prev) => !prev);
|
|
}, []);
|
|
|
|
return (
|
|
<>
|
|
<CodeBlockControls
|
|
copied={copied}
|
|
onCopy={handleCopy}
|
|
collapsible={collapsible}
|
|
collapsed={collapsed}
|
|
onToggle={toggleCollapse}
|
|
/>
|
|
<Scroll
|
|
direction={collapsed ? 'Both' : 'Horizontal'}
|
|
variant="Secondary"
|
|
size="300"
|
|
visibility="Hover"
|
|
hideTrack
|
|
>
|
|
<div id="code-block-content" className={css.CodeBlockInternal({ collapsed })}>
|
|
{domToReact(children, opts)}
|
|
</div>
|
|
</Scroll>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export const getReactCustomHtmlParser = (
|
|
mx: MatrixClient,
|
|
roomId: string | undefined,
|
|
params: {
|
|
linkifyOpts: LinkifyOpts;
|
|
highlightRegex?: RegExp;
|
|
handleSpoilerClick?: ReactEventHandler<HTMLElement>;
|
|
handleMentionClick?: ReactEventHandler<HTMLElement>;
|
|
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 (
|
|
<Text {...props} className={css.Heading} size="H2">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h2') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H3">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h3') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H4">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h4') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H4">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h5') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H5">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'h6') {
|
|
return (
|
|
<Text {...props} className={css.Heading} size="H6">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'p') {
|
|
return (
|
|
<Text {...props} className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit">
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'pre') {
|
|
return (
|
|
<Text {...props} as="pre" className={css.CodeBlock}>
|
|
{CodeBlock(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'blockquote') {
|
|
return (
|
|
<Text {...props} size="Inherit" as="blockquote" className={css.BlockQuote}>
|
|
{domToReact(children, opts)}
|
|
</Text>
|
|
);
|
|
}
|
|
|
|
if (name === 'ul') {
|
|
return (
|
|
<ul {...props} className={css.List}>
|
|
{domToReact(children, opts)}
|
|
</ul>
|
|
);
|
|
}
|
|
if (name === 'ol') {
|
|
return (
|
|
<ol {...props} className={css.List}>
|
|
{domToReact(children, opts)}
|
|
</ol>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<ErrorBoundary fallback={<code {...props}>{codeReact}</code>}>
|
|
<Suspense fallback={<code {...props}>{codeReact}</code>}>
|
|
<ReactPrism>
|
|
{(ref) => (
|
|
<code ref={ref} {...props} className={lang}>
|
|
{codeReact}
|
|
</code>
|
|
)}
|
|
</ReactPrism>
|
|
</Suspense>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
} else {
|
|
return (
|
|
<code className={css.Code} {...props}>
|
|
{domToReact(children, opts)}
|
|
</code>
|
|
);
|
|
}
|
|
}
|
|
|
|
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 (
|
|
<span
|
|
{...props}
|
|
role="button"
|
|
tabIndex={params.handleSpoilerClick ? 0 : -1}
|
|
onKeyDown={params.handleSpoilerClick}
|
|
onClick={params.handleSpoilerClick}
|
|
className={css.Spoiler()}
|
|
aria-pressed
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{domToReact(children, opts)}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
if (name === 'img') {
|
|
const htmlSrc = mxcUrlToHttp(mx, props.src, params.useAuthentication);
|
|
if (htmlSrc && props.src.startsWith('mxc://') === false) {
|
|
return (
|
|
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
|
|
{props.alt || props.title || htmlSrc}
|
|
</a>
|
|
);
|
|
}
|
|
if (htmlSrc && 'data-mx-emoticon' in props) {
|
|
return (
|
|
<span className={css.EmoticonBase}>
|
|
<span className={css.Emoticon()}>
|
|
<img {...props} className={css.EmoticonImg} src={htmlSrc} />
|
|
</span>
|
|
</span>
|
|
);
|
|
}
|
|
if (htmlSrc) return <img {...props} className={css.Img} src={htmlSrc} />;
|
|
}
|
|
}
|
|
|
|
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 <Linkify options={params.linkifyOpts}>{jsx}</Linkify>;
|
|
}
|
|
return jsx;
|
|
}
|
|
return undefined;
|
|
},
|
|
};
|
|
return opts;
|
|
};
|