Use formatted_body to parse markdown (#133) and partially implement #105, #19

Signed-off-by: Ajay Bura <ajbura@gmail.com>
This commit is contained in:
Ajay Bura 2021-11-21 14:30:21 +05:30
parent 7e7a5e692e
commit 2479dc4096
9 changed files with 271 additions and 2048 deletions

2007
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -24,6 +24,7 @@
"flux": "^4.0.1", "flux": "^4.0.1",
"formik": "^2.2.9", "formik": "^2.2.9",
"html-react-parser": "^1.2.7", "html-react-parser": "^1.2.7",
"linkify-html": "^3.0.3",
"linkify-react": "^3.0.3", "linkify-react": "^3.0.3",
"linkifyjs": "^3.0.3", "linkifyjs": "^3.0.3",
"matrix-js-sdk": "^12.4.1", "matrix-js-sdk": "^12.4.1",
@ -34,10 +35,7 @@
"react-autosize-textarea": "^7.1.0", "react-autosize-textarea": "^7.1.0",
"react-dom": "^17.0.2", "react-dom": "^17.0.2",
"react-google-recaptcha": "^2.1.0", "react-google-recaptcha": "^2.1.0",
"react-markdown": "^6.0.1",
"react-modal": "^3.13.1", "react-modal": "^3.13.1",
"react-syntax-highlighter": "^15.4.3",
"remark-gfm": "^1.0.0",
"sanitize-html": "^2.5.3", "sanitize-html": "^2.5.3",
"tippy.js": "^6.3.1", "tippy.js": "^6.3.1",
"twemoji": "^13.1.0" "twemoji": "^13.1.0"

View file

@ -32,7 +32,8 @@
@mixin scroll { @mixin scroll {
overflow: hidden; overflow: hidden;
overscroll-behavior: none; // Below code stop scroll when x-scrollable content come in timeline
// overscroll-behavior: none;
@extend .firefox-scrollbar; @extend .firefox-scrollbar;
@extend .webkit-scrollbar; @extend .webkit-scrollbar;
@extend .webkit-scrollbar-track; @extend .webkit-scrollbar-track;

View file

@ -3,11 +3,7 @@ import React, { useState, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import './Message.scss'; import './Message.scss';
import Linkify from 'linkify-react'; import linkifyHtml from 'linkify-html';
import ReactMarkdown from 'react-markdown';
import gfm from 'remark-gfm';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { coy } from 'react-syntax-highlighter/dist/esm/styles/prism';
import parse from 'html-react-parser'; import parse from 'html-react-parser';
import twemoji from 'twemoji'; import twemoji from 'twemoji';
import dateFormat from 'dateformat'; import dateFormat from 'dateformat';
@ -38,33 +34,7 @@ import PencilIC from '../../../../public/res/ic/outlined/pencil.svg';
import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg'; import TickMarkIC from '../../../../public/res/ic/outlined/tick-mark.svg';
import BinIC from '../../../../public/res/ic/outlined/bin.svg'; import BinIC from '../../../../public/res/ic/outlined/bin.svg';
const components = { import sanitize from './sanitize';
code({
// eslint-disable-next-line react/prop-types
inline, className, children,
}) {
const match = /language-(\w+)/.exec(className || '');
return !inline && match ? (
<SyntaxHighlighter
style={coy}
language={match[1]}
PreTag="div"
showLineNumbers
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
) : (
<code className={className}>{String(children)}</code>
);
},
};
function linkifyContent(content) {
return <Linkify options={{ target: { url: '_blank' } }}>{content}</Linkify>;
}
function genMarkdown(content) {
return <ReactMarkdown remarkPlugins={[gfm]} components={components} linkTarget="_blank">{content}</ReactMarkdown>;
}
function PlaceholderMessage() { function PlaceholderMessage() {
return ( return (
@ -91,7 +61,7 @@ function MessageHeader({
return ( return (
<div className="message__header"> <div className="message__header">
<div style={{ color }} className="message__profile"> <div style={{ color }} className="message__profile">
<Text variant="b1">{name}</Text> <Text variant="b1">{parse(twemoji.parse(name))}</Text>
<Text variant="b1">{userId}</Text> <Text variant="b1">{userId}</Text>
</div> </div>
<div className="message__time"> <div className="message__time">
@ -112,7 +82,7 @@ function MessageReply({ name, color, body }) {
<div className="message__reply"> <div className="message__reply">
<Text variant="b2"> <Text variant="b2">
<RawIcon color={color} size="extra-small" src={ReplyArrowIC} /> <RawIcon color={color} size="extra-small" src={ReplyArrowIC} />
<span style={{ color }}>{name}</span> <span style={{ color }}>{parse(twemoji.parse(name))}</span>
<>{` ${body}`}</> <>{` ${body}`}</>
</Text> </Text>
</div> </div>
@ -132,11 +102,16 @@ function MessageBody({
isEdited, isEdited,
msgType, msgType,
}) { }) {
// if body is not string it is a React( element.
if (typeof body !== 'string') return <div className="message__body">{body}</div>;
const content = twemoji.parse(isCustomHTML ? sanitize(body) : body);
const linkified = linkifyHtml(content, { target: '_blank', rel: 'noreferrer noopener' });
return ( return (
<div className="message__body"> <div className="message__body">
<div className="text text-b1"> <div className="text text-b1">
{ msgType === 'm.emote' && `* ${senderName} ` } { msgType === 'm.emote' && `* ${senderName} ` }
{ isCustomHTML ? genMarkdown(body) : linkifyContent(body) } { parse(linkified) }
</div> </div>
{ isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>} { isEdited && <Text className="message__body-edited" variant="b3">(edited)</Text>}
</div> </div>
@ -383,18 +358,16 @@ function parseReply(rawBody) {
body, body,
}; };
} }
function getEditedBody(eventId, editedTimeline) { function getEditedBody(editedMEvent) {
const editedList = editedTimeline.get(eventId);
const editedMEvent = editedList[editedList.length - 1];
const newContent = editedMEvent.getContent()['m.new_content']; const newContent = editedMEvent.getContent()['m.new_content'];
if (typeof newContent === 'undefined') return [null, false]; if (typeof newContent === 'undefined') return [null, false, null];
const isCustomHTML = newContent.format === 'org.matrix.custom.html'; const isCustomHTML = newContent.format === 'org.matrix.custom.html';
const parsedContent = parseReply(newContent.body); const parsedContent = parseReply(newContent.body);
if (parsedContent === null) { if (parsedContent === null) {
return [newContent.body, isCustomHTML]; return [newContent.body, isCustomHTML, newContent.formatted_body ?? null];
} }
return [parsedContent.body, isCustomHTML]; return [parsedContent.body, isCustomHTML, newContent.formatted_body ?? null];
} }
function Message({ mEvent, isBodyOnly, roomTimeline }) { function Message({ mEvent, isBodyOnly, roomTimeline }) {
@ -406,7 +379,7 @@ function Message({ mEvent, isBodyOnly, roomTimeline }) {
} = roomTimeline; } = roomTimeline;
const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')]; const className = ['message', (isBodyOnly ? 'message--body-only' : 'message--full')];
const content = mEvent.getWireContent(); const content = mEvent.getContent();
const eventId = mEvent.getId(); const eventId = mEvent.getId();
const msgType = content?.msgtype; const msgType = content?.msgtype;
const senderId = mEvent.getSender(); const senderId = mEvent.getSender();
@ -419,16 +392,18 @@ function Message({ mEvent, isBodyOnly, roomTimeline }) {
if (typeof body === 'undefined') return null; if (typeof body === 'undefined') return null;
if (msgType === 'm.emote') className.push('message--type-emote'); if (msgType === 'm.emote') className.push('message--type-emote');
// TODO: these line can be moved to option menu
const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel; const myPowerlevel = room.getMember(mx.getUserId())?.powerLevel;
const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel); const canIRedact = room.currentState.hasSufficientPowerLevelFor('redact', myPowerlevel);
let [reply, reactions, isCustomHTML] = [null, null, content.format === 'org.matrix.custom.html']; let [reply, reactions, isCustomHTML] = [null, null, content.format === 'org.matrix.custom.html'];
const [isEdited, haveReactions] = [editedTimeline.has(eventId), reactionTimeline.has(eventId)]; const [isEdited, haveReactions] = [editedTimeline.has(eventId), reactionTimeline.has(eventId)];
const isReply = typeof content['m.relates_to']?.['m.in_reply_to'] !== 'undefined'; const isReply = typeof content['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
let customHTML = isCustomHTML ? content.formatted_body : null;
if (isEdited) { if (isEdited) {
[body, isCustomHTML] = getEditedBody(eventId, editedTimeline); const editedList = editedTimeline.get(eventId);
const editedMEvent = editedList[editedList.length - 1];
[body, isCustomHTML, customHTML] = getEditedBody(editedMEvent);
if (typeof body !== 'string') return null; if (typeof body !== 'string') return null;
} }
@ -500,7 +475,7 @@ function Message({ mEvent, isBodyOnly, roomTimeline }) {
<MessageBody <MessageBody
senderName={username} senderName={username}
isCustomHTML={isCustomHTML} isCustomHTML={isCustomHTML}
body={isMedia(mEvent) ? genMediaContent(mEvent) : body} body={isMedia(mEvent) ? genMediaContent(mEvent) : customHTML ?? body}
msgType={msgType} msgType={msgType}
isEdited={isEdited} isEdited={isEdited}
/> />

View file

@ -1,5 +1,14 @@
@use '../../atoms/scroll/scrollbar'; @use '../../atoms/scroll/scrollbar';
.custom-emoji {
height: var(--fs-b1);
margin: 0 !important;
margin-right: 2px !important;
padding: 0 !important;
position: relative;
top: 2px;
}
.message, .message,
.ph-msg { .ph-msg {
padding: var(--sp-ultra-tight) var(--sp-normal); padding: var(--sp-ultra-tight) var(--sp-normal);
@ -107,6 +116,10 @@
display: flex; display: flex;
align-items: baseline; align-items: baseline;
& img.emoji {
@extend .custom-emoji;
}
& .message__profile { & .message__profile {
min-width: 0; min-width: 0;
color: var(--tc-surface-high); color: var(--tc-surface-high);
@ -142,6 +155,10 @@
} }
} }
.message__reply { .message__reply {
& img.emoji {
@extend .custom-emoji;
height: 14px;
}
.text { .text {
color: var(--tc-surface-low); color: var(--tc-surface-low);
white-space: nowrap; white-space: nowrap;
@ -163,6 +180,45 @@
& a { & a {
word-break: break-word; word-break: break-word;
} }
& img.emoji,
& img[data-mx-emoticon] {
@extend .custom-emoji;
}
& span[data-mx-pill] {
background-color: hsla(0, 0%, 64%, 0.15);
padding: 0 2px;
border-radius: 4px;
cursor: pointer;
font-weight: 500;
&:hover {
background-color: hsla(0, 0%, 64%, 0.3);
color: var(--tc-surface-high);
}
}
& span[data-mx-spoiler] {
border-radius: 4px;
background-color: rgba(124, 124, 124, 0.5);
color:transparent;
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
& > * {
opacity: 0;
}
&:focus, &:hover {
background-color: transparent;
color: inherit;
user-select: initial;
& > * {
opacity: inherit;
}
}
}
&-edited { &-edited {
color: var(--tc-surface-low); color: var(--tc-surface-low);
} }
@ -327,10 +383,20 @@
@include scrollbar.scroll__h; @include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide; @include scrollbar.scroll--auto-hide;
} }
& pre code { & pre {
display: inline-block;
max-width: 100%;
@include scrollbar.scroll;
@include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide;
& code {
color: var(--tc-surface-normal) !important; color: var(--tc-surface-normal) !important;
white-space: pre;
}
} }
& blockquote { & blockquote {
display: inline-block;
max-width: 100%;
padding-left: var(--sp-extra-tight); padding-left: var(--sp-extra-tight);
border-left: 4px solid var(--bg-surface-active); border-left: 4px solid var(--bg-surface-active);
white-space: initial !important; white-space: initial !important;
@ -372,15 +438,21 @@
list-style: none; list-style: none;
} }
& table { & table {
display: -webkit-box;
width: 100%;
background-color: var(--bg-surface-hover); background-color: var(--bg-surface-hover);
border-radius: calc(var(--bo-radius) / 2); border-radius: calc(var(--bo-radius) / 2);
border-spacing: 0; border-spacing: 0;
border: 1px solid var(--bg-surface-border); border: 1px solid var(--bg-surface-border);
@include scrollbar.scroll;
@include scrollbar.scroll__h;
@include scrollbar.scroll--auto-hide;
& td, & th { & td, & th {
padding: var(--sp-extra-tight); padding: var(--sp-extra-tight);
border: 1px solid var(--bg-surface-border); border: 1px solid var(--bg-surface-border);
border-width: 0 1px 1px 0; border-width: 0 1px 1px 0;
white-space: pre;
&:last-child { &:last-child {
border-width: 0; border-width: 0;
border-bottom-width: 1px; border-bottom-width: 1px;

View file

@ -0,0 +1,144 @@
import sanitizeHtml from 'sanitize-html';
import initMatrix from '../../../client/initMatrix';
function sanitizeColorizedTag(tagName, attributes) {
const attribs = { ...attributes };
const styles = [];
if (attributes['data-mx-color']) {
styles.push(`color: ${attributes['data-mx-color']};`);
}
if (attributes['data-mx-bg-color']) {
styles.push(`background-color: ${attributes['data-mx-bg-color']};`);
}
attribs.style = styles.join(' ');
return { tagName, attribs };
}
function sanitizeLinkTag(tagName, attribs) {
const userLink = attribs.href.match(/^https?:\/\/matrix.to\/#\/(@.+:.+)/);
if (userLink !== null) {
// convert user link to pill
const userId = userLink[1];
return {
tagName: 'span',
attribs: {
'data-mx-pill': userId,
},
};
}
return {
tagName,
attribs: {
...attribs,
target: '_blank',
rel: 'noreferrer noopener',
},
};
}
function sanitizeCodeTag(tagName, attributes) {
const attribs = { ...attributes };
let classes = [];
if (attributes.class) {
classes = attributes.class.split(/\s+/).filter((className) => className.match(/^language-(\w+)/));
}
return {
tagName,
attribs: {
...attribs,
class: classes.join(' '),
},
};
}
function sanitizeImgTag(tagName, attributes) {
const mx = initMatrix.matrixClient;
const { src } = attributes;
const attribs = { ...attributes };
delete attribs.src;
if (src.match(/^mxc:\/\//)) {
attribs.src = mx.mxcUrlToHttp(src);
}
return { tagName, attribs };
}
export default function sanitize(body) {
return sanitizeHtml(body, {
allowedTags: [
'font',
'del',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'blockquote',
'p',
'a',
'ul',
'ol',
'sup',
'sub',
'li',
'b',
'i',
'u',
'strong',
'em',
'strike',
'code',
'hr',
'br',
'div',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'caption',
'pre',
'span',
'img',
'details',
'summary',
],
allowedClasses: {},
allowedAttributes: {
ol: ['start'],
img: ['width', 'height', 'alt', 'title', 'src', 'data-mx-emoticon'],
a: ['name', 'target', 'href', 'rel'],
code: ['class'],
font: ['data-mx-bg-color', 'data-mx-color', 'color', 'style'],
span: ['data-mx-bg-color', 'data-mx-color', 'data-mx-spoiler', 'style', 'data-mx-pill'],
},
allowProtocolRelative: false,
allowedSchemesByTag: {
a: ['https', 'http', 'ftp', 'mailto', 'magnet'],
img: ['https', 'http'],
},
allowedStyles: {
'*': {
color: [/^#(0x)?[0-9a-f]+$/i],
'background-color': [/^#(0x)?[0-9a-f]+$/i],
},
},
nestingLimit: 100,
nonTextTags: [
'style', 'script', 'textarea', 'option', 'mx-reply',
],
transformTags: {
a: sanitizeLinkTag,
img: sanitizeImgTag,
code: sanitizeCodeTag,
font: sanitizeColorizedTag,
span: sanitizeColorizedTag,
},
});
}

View file

@ -13,7 +13,7 @@
.room-intro__content { .room-intro__content {
margin-top: var(--sp-extra-loose); margin-top: var(--sp-extra-loose);
max-width: 640px; width: calc(100% - 88px);
} }
&__name { &__name {
color: var(--tc-surface-high); color: var(--tc-surface-high);

View file

@ -106,7 +106,6 @@ function PeopleDrawer({ roomId }) {
let isGettingMembers = true; let isGettingMembers = true;
const updateMemberList = (event) => { const updateMemberList = (event) => {
if (isGettingMembers) return; if (isGettingMembers) return;
console.log(event?.event?.room_id);
if (event && event?.event?.room_id !== roomId) return; if (event && event?.event?.room_id !== roomId) return;
setMemberList( setMemberList(
simplyfiMembers( simplyfiMembers(

View file

@ -1,3 +1,5 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable react/prop-types */ /* eslint-disable react/prop-types */
import React, { useState, useEffect, useLayoutEffect } from 'react'; import React, { useState, useEffect, useLayoutEffect } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
@ -8,6 +10,7 @@ import dateFormat from 'dateformat';
import initMatrix from '../../../client/initMatrix'; import initMatrix from '../../../client/initMatrix';
import cons from '../../../client/state/cons'; import cons from '../../../client/state/cons';
import { diffMinutes, isNotInSameDay } from '../../../util/common'; import { diffMinutes, isNotInSameDay } from '../../../util/common';
import { openProfileViewer } from '../../../client/action/navigation';
import Divider from '../../atoms/divider/Divider'; import Divider from '../../atoms/divider/Divider';
import { Message, PlaceholderMessage } from '../../molecules/message/Message'; import { Message, PlaceholderMessage } from '../../molecules/message/Message';
@ -188,8 +191,16 @@ function RoomViewContent({
} }
}, [onStateUpdate]); }, [onStateUpdate]);
const handleOnClickCapture = (e) => {
const { target } = e;
const userId = target.getAttribute('data-mx-pill');
if (!userId) return;
openProfileViewer(userId, roomId);
};
let prevMEvent = null; let prevMEvent = null;
function renderMessage(mEvent) { const renderMessage = (mEvent) => {
const isContentOnly = (prevMEvent !== null && prevMEvent.getType() !== 'm.room.member' const isContentOnly = (prevMEvent !== null && prevMEvent.getType() !== 'm.room.member'
&& diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES && diffMinutes(mEvent.getDate(), prevMEvent.getDate()) <= MAX_MSG_DIFF_MINUTES
&& prevMEvent.getSender() === mEvent.getSender() && prevMEvent.getSender() === mEvent.getSender()
@ -222,7 +233,7 @@ function RoomViewContent({
<Message mEvent={mEvent} isBodyOnly={isContentOnly} roomTimeline={roomTimeline} /> <Message mEvent={mEvent} isBodyOnly={isContentOnly} roomTimeline={roomTimeline} />
</React.Fragment> </React.Fragment>
); );
} };
const renderTimeline = () => { const renderTimeline = () => {
const { timeline } = roomTimeline; const { timeline } = roomTimeline;
@ -249,7 +260,7 @@ function RoomViewContent({
}; };
return ( return (
<div className="room-view__content"> <div className="room-view__content" onClick={handleOnClickCapture}>
<div className="timeline__wrapper"> <div className="timeline__wrapper">
{ renderTimeline() } { renderTimeline() }
</div> </div>