render custom html with new styles

This commit is contained in:
Ajay Bura 2023-07-24 16:29:52 +05:30
parent e5dd7d3011
commit 1152d1d563
7 changed files with 380 additions and 58 deletions

View file

@ -20,6 +20,9 @@ module.exports = {
ecmaVersion: 'latest',
sourceType: 'module',
},
"globals": {
JSX: "readonly"
},
plugins: [
'react',
'@typescript-eslint'

76
package-lock.json generated
View file

@ -30,12 +30,13 @@
"focus-trap-react": "10.0.2",
"folds": "1.3.0",
"formik": "2.2.9",
"html-react-parser": "3.0.4",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",
@ -2364,13 +2365,13 @@
}
},
"node_modules/domutils": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.0.1.tgz",
"integrity": "sha512-z08c1l761iKhDFtfXO04C7kTdPBLi41zwOZl00WS8b5eiaebNpY00HKbztwBq+e3vyqWNwWF3mP9YLUeqIrF+Q==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz",
"integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==",
"dependencies": {
"dom-serializer": "^2.0.0",
"domelementtype": "^2.3.0",
"domhandler": "^5.0.1"
"domhandler": "^5.0.3"
},
"funding": {
"url": "https://github.com/fb55/domutils?sponsor=1"
@ -2409,9 +2410,9 @@
}
},
"node_modules/entities": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.4.0.tgz",
"integrity": "sha512-oYp7156SP8LkeGD0GF85ad1X9Ai79WtRsZ2gxJqtBuzH+98YUV6jkHEKlZkMbcrjJjIVJNIDP/3WL9wQkoPbWA==",
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"engines": {
"node": ">=0.12"
},
@ -3595,23 +3596,41 @@
}
},
"node_modules/html-dom-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-3.1.2.tgz",
"integrity": "sha512-mLTtl3pVn3HnqZSZzW3xVs/mJAKrG1yIw3wlp+9bdoZHHLaBRvELdpfShiPVLyjPypq1Fugv2KMDoGHW4lVXnw==",
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-4.0.0.tgz",
"integrity": "sha512-TUa3wIwi80f5NF8CVWzkopBVqVAtlawUzJoLwVLHns0XSJGynss4jiY0mTWpiDOsuyw+afP+ujjMgRh9CoZcXw==",
"dependencies": {
"domhandler": "5.0.3",
"htmlparser2": "8.0.1"
"htmlparser2": "9.0.0"
}
},
"node_modules/html-dom-parser/node_modules/htmlparser2": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.0.0.tgz",
"integrity": "sha512-uxbSI98wmFT/G4P2zXx4OVx04qWUmyFPrD2/CNepa2Zo3GPNaCaaxElDgwUrwYWkK1nr9fft0Ya8dws8coDLLQ==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.1.0",
"entities": "^4.5.0"
}
},
"node_modules/html-react-parser": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-3.0.4.tgz",
"integrity": "sha512-va68PSmC7uA6PbOEc9yuw5Mu3OHPXmFKUpkLGvUPdTuNrZ0CJZk1s/8X/FaHjswK/6uZghu2U02tJjussT8+uw==",
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-4.2.0.tgz",
"integrity": "sha512-gzU55AS+FI6qD7XaKe5BLuLFM2Xw0/LodfMWZlxV9uOHe7LCD5Lukx/EgYuBI3c0kLu0XlgFXnSzO0qUUn3Vrg==",
"dependencies": {
"domhandler": "5.0.3",
"html-dom-parser": "3.1.2",
"html-dom-parser": "4.0.0",
"react-property": "2.0.0",
"style-to-js": "1.1.1"
"style-to-js": "1.1.3"
},
"peerDependencies": {
"react": "0.14 || 15 || 16 || 17 || 18"
@ -4202,6 +4221,15 @@
"linkifyjs": "^4.0.0"
}
},
"node_modules/linkify-react": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/linkify-react/-/linkify-react-4.1.1.tgz",
"integrity": "sha512-2K9Y1cUdvq40dFWqCJ//X+WP19nlzIVITFGI93RjLnA0M7KbnxQ/ffC3AZIZaEIrLangF9Hjt3i0GQ9/anEG5A==",
"peerDependencies": {
"linkifyjs": "^4.0.0",
"react": ">= 15.0.0"
}
},
"node_modules/linkifyjs": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.0.2.tgz",
@ -5437,17 +5465,17 @@
}
},
"node_modules/style-to-js": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.1.tgz",
"integrity": "sha512-RJ18Z9t2B02sYhZtfWKQq5uplVctgvjTfLWT7+Eb1zjUjIrWzX5SdlkwLGQozrqarTmEzJJ/YmdNJCUNI47elg==",
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.3.tgz",
"integrity": "sha512-zKI5gN/zb7LS/Vm0eUwjmjrXWw8IMtyA8aPBJZdYiQTXj4+wQ3IucOLIOnF7zCHxvW8UhIGh/uZh/t9zEHXNTQ==",
"dependencies": {
"style-to-object": "0.3.0"
"style-to-object": "0.4.1"
}
},
"node_modules/style-to-object": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz",
"integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==",
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.4.1.tgz",
"integrity": "sha512-HFpbb5gr2ypci7Qw+IOhnP2zOU7e77b+rzM+wTzXzfi1PrtBCX0E7Pk4wL4iTLnhzZ+JgEGAhX81ebTg/aYjQw==",
"dependencies": {
"inline-style-parser": "0.1.1"
}

View file

@ -40,12 +40,13 @@
"focus-trap-react": "10.0.2",
"folds": "1.3.0",
"formik": "2.2.9",
"html-react-parser": "3.0.4",
"html-react-parser": "4.2.0",
"immer": "9.0.16",
"is-hotkey": "0.2.0",
"jotai": "1.12.0",
"katex": "0.16.4",
"linkify-html": "4.0.2",
"linkify-react": "4.1.1",
"linkifyjs": "4.0.2",
"matrix-js-sdk": "24.1.0",
"millify": "6.1.0",

View file

@ -2,7 +2,7 @@ import { Scroll, Text } from 'folds';
import React from 'react';
import { RenderElementProps, RenderLeafProps, useFocused, useSelected } from 'slate-react';
import * as css from './Elements.css';
import * as css from '../../styles/CustomHtml.css';
import { EmoticonElement, LinkElement, MentionElement } from './slate';
import { useMatrixClient } from '../../hooks/useMatrixClient';
@ -145,7 +145,13 @@ export function RenderElement({ attributes, element, children }: RenderElementPr
case BlockType.CodeBlock:
return (
<Text as="pre" className={css.CodeBlock} {...attributes}>
<Scroll direction="Horizontal" variant="Warning" size="300" visibility="Hover" hideTrack>
<Scroll
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{children}</div>
</Scroll>
</Text>
@ -242,7 +248,7 @@ export function RenderLeaf({ attributes, leaf, children }: RenderLeafProps) {
);
if (leaf.spoiler)
child = (
<span className={css.Spoiler} {...attributes}>
<span className={css.Spoiler()} {...attributes}>
<InlineChromiumBugfix />
{child}
</span>

View file

@ -4,6 +4,7 @@ import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
@ -16,18 +17,21 @@ import {
Room,
RoomEvent,
} from 'matrix-js-sdk';
import parse from 'html-react-parser';
import parse, { HTMLReactParserOptions } from 'html-react-parser';
import to from 'await-to-js';
import { Box, Scroll, Text, color, config } from 'folds';
import Linkify from 'linkify-react';
import { getMxIdLocalPart } from '../../utils/matrix';
import colorMXID from '../../../util/colorMXID';
import { sanitizeCustomHtml } from '../../../util/sanitize';
import { sanitizeCustomHtml } from '../../utils/sanitize';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator';
import { useAlive } from '../../hooks/useAlive';
import { scrollToBottom } from '../../utils/dom';
import { CompactMessagePlaceholder } from '../../components/message';
import { CompactMessage } from '../../components/message/CompactMessage';
import { LINKIFY_OPTS, getReactCustomHtmlParser } from '../../plugins/react-custom-html-parser';
import { getMemberDisplayName } from '../../utils/room';
export const getLiveTimeline = (room: Room): EventTimeline =>
room.getUnfilteredTimelineSet().getLiveTimeline();
@ -171,6 +175,11 @@ export function RoomTimeline({ room, eventId }: RoomTimelineProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const eventArriveCountRef = useRef(0);
const htmlReactParserOptions = useMemo<HTMLReactParserOptions>(
() => getReactCustomHtmlParser(mx, room),
[mx, room]
);
const [timeline, setTimeline] = useState<Timeline>(() => {
const linkedTimelines = eventId ? [] : getLinkedTimelines(getLiveTimeline(room));
const evLength = getTimelinesTotalLength(linkedTimelines);
@ -275,33 +284,51 @@ export function RoomTimeline({ room, eventId }: RoomTimelineProps) {
const { body } = mEvent.getContent();
if (!body) return null;
const customBody = mEvent.getContent().formatted_body;
const senderId = mEvent.getSender() ?? '';
return (
<CompactMessage key={mEvent.getId()} data-message-item={item}>
<Box
style={{
position: 'sticky',
top: config.space.S100,
maxWidth: 170,
width: '100%',
}}
gap="200"
shrink="No"
justifyContent="SpaceBetween"
alignItems="Baseline"
>
<Text size="T200" priority="300">
<Text style={{ flexShrink: 0 }} size="T200" priority="300">
{new Date(mEvent.getTs()).toLocaleTimeString()}
</Text>
<Text
truncate
style={{
maxWidth: 120,
color: colorMXID(mEvent.getSender()),
color: colorMXID(senderId),
}}
>
<b>{getMxIdLocalPart(mEvent?.getSender() ?? '')}</b>
<b>{getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId)}</b>
</Text>
</Box>
<Box
grow="Yes"
direction="Column"
// style={{
// borderRadius: 8,
// padding: 8,
// backgroundColor: color.SurfaceVariant.Container,
// }}
>
<Text as="div" style={{ whiteSpace: !customBody ? 'pre-wrap' : 'initial' }}>
{customBody ? (
parse(sanitizeCustomHtml(customBody), htmlReactParserOptions)
) : (
<Linkify options={LINKIFY_OPTS}>{body}</Linkify>
)}
</Text>
</Box>
<Text as="div">
{customBody ? parse(sanitizeCustomHtml(mx, customBody)) : body}
</Text>
</CompactMessage>
);
})}

View file

@ -0,0 +1,223 @@
/* eslint-disable jsx-a11y/alt-text */
import React from 'react';
import {
Element,
Text as DOMText,
HTMLReactParserOptions,
attributesToProps,
domToReact,
} from 'html-react-parser';
import { MatrixClient, Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import { Scroll, Text } from 'folds';
import { Opts as LinkifyOpts } from 'linkifyjs';
import Linkify from 'linkify-react';
import * as css from '../styles/CustomHtml.css';
import { getMxIdLocalPart, getRoomWithCanonicalAlias } from '../utils/matrix';
import { getMemberDisplayName } from '../utils/room';
export const LINKIFY_OPTS: LinkifyOpts = {
attributes: {
target: '_blank',
rel: 'noreferrer noopener',
},
validate: {
url: (value) => /^(https|http|ftp|mailto|magnet)?:/.test(value),
},
};
export const getReactCustomHtmlParser = (mx: MatrixClient, room: Room): 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 className={css.Heading} size="H2" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'h2') {
return (
<Text className={css.Heading} size="H3" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'h3') {
return (
<Text className={css.Heading} size="H4" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'h4') {
return (
<Text className={css.Heading} size="H4" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'h5') {
return (
<Text className={css.Heading} size="H5" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'h6') {
return (
<Text className={css.Heading} size="H6" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'p') {
return (
<Text className={classNames(css.Paragraph, css.MarginSpaced)} size="Inherit" {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'pre') {
return (
<Text as="pre" className={css.CodeBlock} {...props}>
<Scroll
direction="Horizontal"
variant="Secondary"
size="300"
visibility="Hover"
hideTrack
>
<div className={css.CodeBlockInternal}>{domToReact(children, opts)}</div>
</Scroll>
</Text>
);
}
if (name === 'blockquote') {
return (
<Text size="Inherit" as="blockquote" className={css.BlockQuote} {...props}>
{domToReact(children, opts)}
</Text>
);
}
if (name === 'ul') {
return (
<ul className={css.List} {...props}>
{domToReact(children, opts)}
</ul>
);
}
if (name === 'ol') {
return (
<ol className={css.List} {...props}>
{domToReact(children, opts)}
</ol>
);
}
if (name === 'code' && parent && 'name' in parent && parent.name !== 'pre') {
return (
<code className={css.Code} {...props}>
{domToReact(children, opts)}
</code>
);
}
if (name === 'a') {
const mention = decodeURIComponent(props.href).match(
/^https?:\/\/matrix.to\/#\/((@|#|!).+:[^?/]+)/
);
if (mention) {
// convert mention link to pill
const mentionId = mention[1];
const mentionPrefix = mention[2];
if (mentionPrefix === '#' || mentionPrefix === '!') {
const mentionRoom =
mentionPrefix === '#'
? getRoomWithCanonicalAlias(mx, mentionId)
: mx.getRoom(mentionId);
const mentionName = mentionRoom?.name;
const mentionDisplayName =
mentionName && (mentionName.startsWith('#') ? mentionName : `#${mentionName}`);
return (
<span
className={css.Mention({
highlight: room.roomId === (mentionRoom?.roomId ?? mentionId),
})}
data-mx-pill={mentionId}
{...props}
>
{mentionDisplayName ?? mentionId}
</span>
);
}
if (mentionPrefix === '@')
return (
<span
className={css.Mention({
highlight: mx.getUserId() === mentionId,
})}
data-mx-pill={mentionId}
{...props}
>
{`@${getMemberDisplayName(room, mentionId) ?? getMxIdLocalPart(mentionId)}`}
</span>
);
}
}
if (name === 'span' && 'data-mx-spoiler' in props) {
return (
<span className={css.Spoiler()} {...props}>
{domToReact(children)}
</span>
);
}
if (name === 'img') {
const htmlSrc = mx.mxcUrlToHttp(props.src);
if (htmlSrc && props.src.startsWith('mxc://') === false) {
return (
<a href={htmlSrc} target="_blank" rel="noreferrer noopener">
{props.alt && htmlSrc}
</a>
);
}
if (htmlSrc && 'data-mx-emoticon' in props) {
return (
<span className={css.EmoticonBase}>
<span className={css.Emoticon()} contentEditable={false}>
<img className={css.EmoticonImg} src={htmlSrc} data-mx-emoticon />
</span>
</span>
);
}
if (htmlSrc) return <img className={css.Img} {...props} src={htmlSrc} />;
}
}
if (
domNode instanceof DOMText &&
!(domNode.parent && 'name' in domNode.parent && domNode.parent.name === 'code')
) {
return <Linkify options={LINKIFY_OPTS}>{domNode.data}</Linkify>;
}
return undefined;
},
};
return opts;
};

View file

@ -2,34 +2,49 @@ import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, config, DefaultReset, toRem } from 'folds';
const MarginBottom = style({
export const MarginSpaced = style({
marginBottom: config.space.S200,
marginTop: config.space.S200,
selectors: {
'&:first-child': {
marginTop: 0,
},
'&:last-child': {
marginBottom: 0,
},
},
});
export const Paragraph = style([MarginBottom]);
export const Paragraph = style([DefaultReset]);
export const Heading = style([MarginBottom]);
export const Heading = style([
DefaultReset,
MarginSpaced,
{
marginTop: config.space.S400,
selectors: {
'&:first-child': {
marginTop: 0,
},
},
},
]);
export const BlockQuote = style([
DefaultReset,
MarginBottom,
MarginSpaced,
{
paddingLeft: config.space.S200,
borderLeft: `${config.borderWidth.B700} solid ${color.SurfaceVariant.ContainerLine}`,
borderLeft: `${config.borderWidth.B700} solid ${color.Surface.ContainerLine}`,
fontStyle: 'italic',
},
]);
const BaseCode = style({
fontFamily: 'monospace',
color: color.Warning.OnContainer,
background: color.Warning.Container,
border: `${config.borderWidth.B300} solid ${color.Warning.ContainerLine}`,
color: color.Secondary.OnContainer,
background: color.Secondary.Container,
border: `${config.borderWidth.B300} solid ${color.Secondary.ContainerLine}`,
borderRadius: config.radii.R300,
});
@ -40,29 +55,48 @@ export const Code = style([
padding: `0 ${config.space.S100}`,
},
]);
export const Spoiler = style([
DefaultReset,
{
padding: `0 ${config.space.S100}`,
backgroundColor: color.SurfaceVariant.ContainerActive,
borderRadius: config.radii.R300,
},
]);
export const CodeBlock = style([DefaultReset, BaseCode, MarginBottom]);
export const Spoiler = recipe({
base: [
DefaultReset,
{
padding: `0 ${config.space.S100}`,
backgroundColor: color.SurfaceVariant.ContainerActive,
borderRadius: config.radii.R300,
},
],
variants: {
active: {
true: {
color: 'transparent',
},
},
},
});
export const CodeBlock = style([DefaultReset, BaseCode, MarginSpaced]);
export const CodeBlockInternal = style({
padding: `${config.space.S200} ${config.space.S200} 0`,
});
export const List = style([
DefaultReset,
MarginBottom,
MarginSpaced,
{
padding: `0 ${config.space.S100}`,
paddingLeft: config.space.S600,
},
]);
export const Img = style([
DefaultReset,
MarginSpaced,
{
maxWidth: toRem(296),
borderRadius: config.radii.R300,
},
]);
export const InlineChromiumBugfix = style({
fontSize: 0,
lineHeight: 0,
@ -83,9 +117,9 @@ export const Mention = recipe({
variants: {
highlight: {
true: {
backgroundColor: color.Primary.Container,
color: color.Primary.OnContainer,
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Primary.ContainerLine}`,
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`,
},
},
focus: {