Allow rendering messages as plaintext (#805)
* Parse room input from user id and emoji * Add more plain outputs * Add reply support * Always include formatted reply * Add room mention parser * Allow single linebreak after codeblock * Remove margin from math display blocks * Escape shrug * Rewrite HTML tag function * Normalize def keys * Fix embedding replies into replies * Don't add margin to file name * Collapse spaces in HTML message body * Don't crash with no plaintext rendering * Add blockquote support * Remove ref support * Fix image html rendering * Remove debug output * Remove duplicate default option value * Add table plain rendering support * Correctly handle paragraph padding when mixed with block content * Simplify links if possible * Make blockquote plain rendering better * Don't error when emojis are matching but not found * Allow plain only messages with newlines * Set user id as user mention fallback * Fix mixed up variable name * Replace replaceAll with replace
This commit is contained in:
parent
efda9991f2
commit
15c1f6dadf
10 changed files with 368 additions and 211 deletions
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import './Math.scss';
|
||||
|
||||
import katex from 'katex';
|
||||
import 'katex/dist/katex.min.css';
|
||||
|
|
3
src/app/atoms/math/Math.scss
Normal file
3
src/app/atoms/math/Math.scss
Normal file
|
@ -0,0 +1,3 @@
|
|||
.katex-display {
|
||||
margin: 0 !important;
|
||||
}
|
|
@ -8,7 +8,9 @@ import './Message.scss';
|
|||
import { twemojify } from '../../../util/twemojify';
|
||||
|
||||
import initMatrix from '../../../client/initMatrix';
|
||||
import { getUsername, getUsernameOfRoomMember, parseReply } from '../../../util/matrixUtil';
|
||||
import {
|
||||
getUsername, getUsernameOfRoomMember, parseReply, trimHTMLReply,
|
||||
} from '../../../util/matrixUtil';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getEventCords } from '../../../util/common';
|
||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||
|
@ -248,7 +250,7 @@ const MessageBody = React.memo(({
|
|||
if (!isCustomHTML) {
|
||||
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
||||
// white-space: pre-wrap) in order to preserve newlines
|
||||
content = (<p>{content}</p>);
|
||||
content = (<p className="message__body-plain">{content}</p>);
|
||||
}
|
||||
|
||||
return (
|
||||
|
@ -729,23 +731,23 @@ function Message({
|
|||
let { body } = content;
|
||||
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
||||
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
||||
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
||||
|
||||
const edit = useCallback(() => {
|
||||
setEdit(eventId);
|
||||
}, []);
|
||||
const reply = useCallback(() => {
|
||||
replyTo(senderId, mEvent.getId(), body);
|
||||
}, [body]);
|
||||
replyTo(senderId, mEvent.getId(), body, customHTML);
|
||||
}, [body, customHTML]);
|
||||
|
||||
if (msgType === 'm.emote') className.push('message--type-emote');
|
||||
|
||||
let isCustomHTML = content.format === 'org.matrix.custom.html';
|
||||
const isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
||||
const haveReactions = roomTimeline
|
||||
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
||||
: false;
|
||||
const isReply = !!mEvent.replyEventId;
|
||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
||||
|
||||
if (isEdited) {
|
||||
const editedList = editedTimeline.get(eventId);
|
||||
|
@ -755,6 +757,7 @@ function Message({
|
|||
|
||||
if (isReply) {
|
||||
body = parseReply(body)?.body ?? body;
|
||||
customHTML = trimHTMLReply(customHTML);
|
||||
}
|
||||
|
||||
if (typeof body !== 'string') body = '';
|
||||
|
|
|
@ -163,7 +163,7 @@
|
|||
.message__body {
|
||||
word-break: break-word;
|
||||
|
||||
& > .text > * {
|
||||
& > .text > .message__body-plain {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
|
@ -174,8 +174,8 @@
|
|||
white-space: initial !important;
|
||||
}
|
||||
|
||||
& p:not(:last-child) {
|
||||
margin-bottom: var(--sp-normal);
|
||||
& > .text > p + p {
|
||||
margin-top: var(--sp-normal);
|
||||
}
|
||||
|
||||
& span[data-mx-pill] {
|
||||
|
|
|
@ -143,9 +143,11 @@ function RoomViewInput({
|
|||
textAreaRef.current.focus();
|
||||
}
|
||||
|
||||
function setUpReply(userId, eventId, body) {
|
||||
function setUpReply(userId, eventId, body, formattedBody) {
|
||||
setReplyTo({ userId, eventId, body });
|
||||
roomsInput.setReplyTo(roomId, { userId, eventId, body });
|
||||
roomsInput.setReplyTo(roomId, {
|
||||
userId, eventId, body, formattedBody,
|
||||
});
|
||||
focusInput();
|
||||
}
|
||||
|
||||
|
|
|
@ -139,12 +139,13 @@ export function openViewSource(event) {
|
|||
});
|
||||
}
|
||||
|
||||
export function replyTo(userId, eventId, body) {
|
||||
export function replyTo(userId, eventId, body, formattedBody) {
|
||||
appDispatcher.dispatch({
|
||||
type: cons.actions.navigation.CLICK_REPLY_TO,
|
||||
userId,
|
||||
eventId,
|
||||
body,
|
||||
formattedBody,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -6,11 +6,9 @@ import { getBlobSafeMimeType } from '../../util/mimetypes';
|
|||
import { sanitizeText } from '../../util/sanitize';
|
||||
import cons from './cons';
|
||||
import settings from './settings';
|
||||
import { htmlOutput, parser } from '../../util/markdown';
|
||||
import { markdown, plain } from '../../util/markdown';
|
||||
|
||||
const blurhashField = 'xyz.amorgan.blurhash';
|
||||
const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
|
||||
const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
|
||||
|
||||
function encodeBlurhash(img) {
|
||||
const canvas = document.createElement('canvas');
|
||||
|
@ -100,91 +98,6 @@ function getVideoThumbnail(video, width, height, mimeType) {
|
|||
});
|
||||
}
|
||||
|
||||
function getFormattedBody(markdown) {
|
||||
let content = parser(markdown);
|
||||
if (content.length === 1 && content[0].type === 'paragraph') {
|
||||
content = content[0].content;
|
||||
}
|
||||
return htmlOutput(content);
|
||||
}
|
||||
|
||||
function getReplyFormattedBody(roomId, reply) {
|
||||
const replyToLink = `<a href="https://matrix.to/#/${roomId}/${reply.eventId}">In reply to</a>`;
|
||||
const userLink = `<a href="https://matrix.to/#/${reply.userId}">${reply.userId}</a>`;
|
||||
const formattedReply = getFormattedBody(reply.body.replace(/\n/g, '\n> '));
|
||||
return `<mx-reply><blockquote>${replyToLink}${userLink}<br />${formattedReply}</blockquote></mx-reply>`;
|
||||
}
|
||||
|
||||
function bindReplyToContent(roomId, reply, content) {
|
||||
const newContent = { ...content };
|
||||
newContent.body = `> <${reply.userId}> ${reply.body.replace(/\n/g, '\n> ')}`;
|
||||
newContent.body += `\n\n${content.body}`;
|
||||
newContent.format = 'org.matrix.custom.html';
|
||||
newContent['m.relates_to'] = content['m.relates_to'] || {};
|
||||
newContent['m.relates_to']['m.in_reply_to'] = { event_id: reply.eventId };
|
||||
|
||||
const formattedReply = getReplyFormattedBody(roomId, reply);
|
||||
newContent.formatted_body = formattedReply + (content.formatted_body || content.body);
|
||||
return newContent;
|
||||
}
|
||||
|
||||
function findAndReplace(text, regex, filter, replace) {
|
||||
let copyText = text;
|
||||
Array.from(copyText.matchAll(regex))
|
||||
.filter(filter)
|
||||
.reverse() /* to replace backward to forward */
|
||||
.forEach((match) => {
|
||||
const matchText = match[0];
|
||||
const tag = replace(match);
|
||||
|
||||
copyText = copyText.substr(0, match.index)
|
||||
+ tag
|
||||
+ copyText.substr(match.index + matchText.length);
|
||||
});
|
||||
return copyText;
|
||||
}
|
||||
|
||||
function formatUserPill(room, text) {
|
||||
const { userIdsToDisplayNames } = room.currentState;
|
||||
return findAndReplace(
|
||||
text,
|
||||
MXID_REGEX,
|
||||
(match) => userIdsToDisplayNames[match[0]],
|
||||
(match) => (
|
||||
`<a href="https://matrix.to/#/${match[0]}">@${userIdsToDisplayNames[match[0]]}</a>`
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function formatEmoji(mx, room, roomList, text) {
|
||||
const parentIds = roomList.getAllParentSpaces(room.roomId);
|
||||
const parentRooms = [...parentIds].map((id) => mx.getRoom(id));
|
||||
const allEmoji = getShortcodeToEmoji(mx, [room, ...parentRooms]);
|
||||
|
||||
return findAndReplace(
|
||||
text,
|
||||
SHORTCODE_REGEX,
|
||||
(match) => allEmoji.has(match[1]),
|
||||
(match) => {
|
||||
const emoji = allEmoji.get(match[1]);
|
||||
|
||||
let tag;
|
||||
if (emoji.mxc) {
|
||||
tag = `<img data-mx-emoticon="" src="${
|
||||
emoji.mxc
|
||||
}" alt=":${
|
||||
emoji.shortcode
|
||||
}:" title=":${
|
||||
emoji.shortcode
|
||||
}:" height="32" />`;
|
||||
} else {
|
||||
tag = emoji.unicode;
|
||||
}
|
||||
return tag;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class RoomsInput extends EventEmitter {
|
||||
constructor(mx, roomList) {
|
||||
super();
|
||||
|
@ -274,9 +187,76 @@ class RoomsInput extends EventEmitter {
|
|||
return this.roomIdToInput.get(roomId)?.isSending || false;
|
||||
}
|
||||
|
||||
async sendInput(roomId, options) {
|
||||
const { msgType, autoMarkdown } = options;
|
||||
getContent(roomId, options, message, reply, edit) {
|
||||
const msgType = options?.msgType || 'm.text';
|
||||
const autoMarkdown = options?.autoMarkdown ?? true;
|
||||
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
|
||||
const userNames = room.currentState.userIdsToDisplayNames;
|
||||
const parentIds = this.roomList.getAllParentSpaces(room.roomId);
|
||||
const parentRooms = [...parentIds].map((id) => this.matrixClient.getRoom(id));
|
||||
const emojis = getShortcodeToEmoji(this.matrixClient, [room, ...parentRooms]);
|
||||
|
||||
const output = settings.isMarkdown && autoMarkdown ? markdown : plain;
|
||||
const body = output(message, { userNames, emojis });
|
||||
|
||||
const content = {
|
||||
body: body.plain,
|
||||
msgtype: msgType,
|
||||
};
|
||||
|
||||
if (!body.onlyPlain || reply) {
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = body.html;
|
||||
}
|
||||
|
||||
if (edit) {
|
||||
content['m.new_content'] = { ...content };
|
||||
content['m.relates_to'] = {
|
||||
event_id: edit.getId(),
|
||||
rel_type: 'm.replace',
|
||||
};
|
||||
|
||||
const isReply = edit.getWireContent()['m.relates_to']?.['m.in_reply_to'];
|
||||
if (isReply) {
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = body.html;
|
||||
}
|
||||
|
||||
content.body = ` * ${content.body}`;
|
||||
if (content.formatted_body) content.formatted_body = ` * ${content.formatted_body}`;
|
||||
|
||||
if (isReply) {
|
||||
const eBody = edit.getContent().body;
|
||||
const replyHead = eBody.substring(0, eBody.indexOf('\n\n'));
|
||||
if (replyHead) content.body = `${replyHead}\n\n${content.body}`;
|
||||
|
||||
const eFBody = edit.getContent().formatted_body;
|
||||
const fReplyHead = eFBody.substring(0, eFBody.indexOf('</mx-reply>'));
|
||||
if (fReplyHead) content.formatted_body = `${fReplyHead}</mx-reply>${content.formatted_body}`;
|
||||
}
|
||||
}
|
||||
|
||||
if (reply) {
|
||||
content['m.relates_to'] = {
|
||||
'm.in_reply_to': {
|
||||
event_id: reply.eventId,
|
||||
},
|
||||
};
|
||||
|
||||
content.body = `> <${reply.userId}> ${reply.body.replace(/\n/g, '\n> ')}\n\n${content.body}`;
|
||||
|
||||
const replyToLink = `<a href="https://matrix.to/#/${encodeURIComponent(roomId)}/${encodeURIComponent(reply.eventId)}">In reply to</a>`;
|
||||
const userLink = `<a href="https://matrix.to/#/${encodeURIComponent(reply.userId)}">${sanitizeText(reply.userId)}</a>`;
|
||||
const fallback = `<mx-reply><blockquote>${replyToLink}${userLink}<br />${reply.formattedBody || sanitizeText(reply.body)}</blockquote></mx-reply>`;
|
||||
content.formatted_body = fallback + content.formatted_body;
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
async sendInput(roomId, options) {
|
||||
const input = this.getInput(roomId);
|
||||
input.isSending = true;
|
||||
this.roomIdToInput.set(roomId, input);
|
||||
|
@ -286,38 +266,7 @@ class RoomsInput extends EventEmitter {
|
|||
}
|
||||
|
||||
if (this.getMessage(roomId).trim() !== '') {
|
||||
const rawMessage = input.message;
|
||||
let content = {
|
||||
body: rawMessage,
|
||||
msgtype: msgType ?? 'm.text',
|
||||
};
|
||||
|
||||
// Apply formatting if relevant
|
||||
let formattedBody = settings.isMarkdown && autoMarkdown
|
||||
? getFormattedBody(rawMessage)
|
||||
: sanitizeText(rawMessage);
|
||||
|
||||
if (autoMarkdown) {
|
||||
formattedBody = formatUserPill(room, formattedBody);
|
||||
formattedBody = formatEmoji(this.matrixClient, room, this.roomList, formattedBody);
|
||||
|
||||
content.body = findAndReplace(
|
||||
content.body,
|
||||
MXID_REGEX,
|
||||
(match) => room.currentState.userIdsToDisplayNames[match[0]],
|
||||
(match) => `@${room.currentState.userIdsToDisplayNames[match[0]]}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (formattedBody !== sanitizeText(rawMessage)) {
|
||||
// Formatting was applied, and we need to switch to custom HTML
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = formattedBody;
|
||||
}
|
||||
|
||||
if (typeof input.replyTo !== 'undefined') {
|
||||
content = bindReplyToContent(roomId, input.replyTo, content);
|
||||
}
|
||||
const content = this.getContent(roomId, options, input.message, input.replyTo);
|
||||
this.matrixClient.sendMessage(roomId, content);
|
||||
}
|
||||
|
||||
|
@ -460,55 +409,13 @@ class RoomsInput extends EventEmitter {
|
|||
}
|
||||
|
||||
async sendEditedMessage(roomId, mEvent, editedBody) {
|
||||
const room = this.matrixClient.getRoom(roomId);
|
||||
const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
|
||||
|
||||
const msgtype = mEvent.getWireContent().msgtype ?? 'm.text';
|
||||
|
||||
const content = {
|
||||
body: ` * ${editedBody}`,
|
||||
msgtype,
|
||||
'm.new_content': {
|
||||
body: editedBody,
|
||||
msgtype,
|
||||
},
|
||||
'm.relates_to': {
|
||||
event_id: mEvent.getId(),
|
||||
rel_type: 'm.replace',
|
||||
},
|
||||
};
|
||||
|
||||
// Apply formatting if relevant
|
||||
let formattedBody = settings.isMarkdown
|
||||
? getFormattedBody(editedBody)
|
||||
: sanitizeText(editedBody);
|
||||
formattedBody = formatUserPill(room, formattedBody);
|
||||
formattedBody = formatEmoji(this.matrixClient, room, this.roomList, formattedBody);
|
||||
|
||||
content.body = findAndReplace(
|
||||
content.body,
|
||||
MXID_REGEX,
|
||||
(match) => room.currentState.userIdsToDisplayNames[match[0]],
|
||||
(match) => `@${room.currentState.userIdsToDisplayNames[match[0]]}`,
|
||||
const content = this.getContent(
|
||||
roomId,
|
||||
{ msgType: mEvent.getWireContent().msgtype },
|
||||
editedBody,
|
||||
null,
|
||||
mEvent,
|
||||
);
|
||||
if (formattedBody !== sanitizeText(editedBody)) {
|
||||
content.formatted_body = ` * ${formattedBody}`;
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content['m.new_content'].formatted_body = formattedBody;
|
||||
content['m.new_content'].format = 'org.matrix.custom.html';
|
||||
}
|
||||
if (isReply) {
|
||||
const evBody = mEvent.getContent().body;
|
||||
const replyHead = evBody.slice(0, evBody.indexOf('\n\n'));
|
||||
const evFBody = mEvent.getContent().formatted_body;
|
||||
const fReplyHead = evFBody.slice(0, evFBody.indexOf('</mx-reply>'));
|
||||
|
||||
content.format = 'org.matrix.custom.html';
|
||||
content.formatted_body = `${fReplyHead}</mx-reply>${(content.formatted_body || content.body)}`;
|
||||
|
||||
content.body = `${replyHead}\n\n${content.body}`;
|
||||
}
|
||||
|
||||
this.matrixClient.sendMessage(roomId, content);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -375,6 +375,7 @@ class Navigation extends EventEmitter {
|
|||
action.userId,
|
||||
action.eventId,
|
||||
action.body,
|
||||
action.formattedBody,
|
||||
);
|
||||
},
|
||||
[cons.actions.navigation.OPEN_SEARCH]: () => {
|
||||
|
|
|
@ -1,25 +1,82 @@
|
|||
import SimpleMarkdown from '@khanacademy/simple-markdown';
|
||||
|
||||
const {
|
||||
defaultRules, parserFor, outputFor, anyScopeRegex, blockRegex, inlineRegex, htmlTag, sanitizeText,
|
||||
defaultRules, parserFor, outputFor, anyScopeRegex, blockRegex, inlineRegex,
|
||||
sanitizeText, sanitizeUrl,
|
||||
} = SimpleMarkdown;
|
||||
|
||||
function htmlTag(tagName, content, attributes, isClosed) {
|
||||
let s = '';
|
||||
Object.entries(attributes || {}).forEach(([k, v]) => {
|
||||
if (v !== undefined) {
|
||||
s += ` ${sanitizeText(k)}`;
|
||||
if (v !== null) s += `="${sanitizeText(v)}"`;
|
||||
}
|
||||
});
|
||||
|
||||
s = `<${tagName}${s}>`;
|
||||
|
||||
if (isClosed === false) {
|
||||
return s;
|
||||
}
|
||||
return `${s}${content}</${tagName}>`;
|
||||
}
|
||||
|
||||
function mathHtml(wrap, node) {
|
||||
return htmlTag(wrap, htmlTag('code', sanitizeText(node.content)), { 'data-mx-maths': node.content });
|
||||
}
|
||||
|
||||
const rules = {
|
||||
...defaultRules,
|
||||
const emojiRegex = /^:([\w-]+):/;
|
||||
|
||||
const plainRules = {
|
||||
Array: {
|
||||
...defaultRules.Array,
|
||||
plain: (arr, output, state) => arr.map((node) => output(node, state)).join(''),
|
||||
},
|
||||
displayMath: {
|
||||
order: defaultRules.list.order + 0.5,
|
||||
match: blockRegex(/^\$\$\n*([\s\S]+?)\n*\$\$/),
|
||||
parse: (capture) => ({ content: capture[1] }),
|
||||
plain: (node) => `$$\n${node.content}\n$$`,
|
||||
html: (node) => mathHtml('div', node),
|
||||
userMention: {
|
||||
order: defaultRules.em.order - 0.9,
|
||||
match: inlineRegex(/^(@\S+:\S+)/),
|
||||
parse: (capture, _, state) => ({
|
||||
content: state.userNames[capture[1]] ? `@${state.userNames[capture[1]]}` : capture[1],
|
||||
id: capture[1],
|
||||
}),
|
||||
plain: (node) => node.content,
|
||||
html: (node) => htmlTag('a', sanitizeText(node.content), {
|
||||
href: `https://matrix.to/#/${encodeURIComponent(node.id)}`,
|
||||
}),
|
||||
},
|
||||
roomMention: {
|
||||
order: defaultRules.em.order - 0.8,
|
||||
match: inlineRegex(/^(#\S+:\S+)/), // TODO: Handle line beginning with roomMention (instead of heading)
|
||||
parse: (capture) => ({ content: capture[1], id: capture[1] }),
|
||||
plain: (node) => node.content,
|
||||
html: (node) => htmlTag('a', sanitizeText(node.content), {
|
||||
href: `https://matrix.to/#/${encodeURIComponent(node.id)}`,
|
||||
}),
|
||||
},
|
||||
emoji: {
|
||||
order: defaultRules.em.order - 0.1,
|
||||
match: (source, state) => {
|
||||
if (!state.inline) return null;
|
||||
const capture = emojiRegex.exec(source);
|
||||
if (!capture) return null;
|
||||
const emoji = state.emojis.get(capture[1]);
|
||||
if (emoji) return capture;
|
||||
return null;
|
||||
},
|
||||
parse: (capture, _, state) => ({ content: capture[1], emoji: state.emojis.get(capture[1]) }),
|
||||
plain: ({ emoji }) => (emoji.mxc
|
||||
? `:${emoji.shortcode}:`
|
||||
: emoji.unicode),
|
||||
html: ({ emoji }) => (emoji.mxc
|
||||
? htmlTag('img', null, {
|
||||
'data-mx-emoticon': null,
|
||||
src: emoji.mxc,
|
||||
alt: `:${emoji.shortcode}:`,
|
||||
title: `:${emoji.shortcode}:`,
|
||||
height: 32,
|
||||
}, false)
|
||||
: emoji.unicode),
|
||||
},
|
||||
newline: {
|
||||
...defaultRules.newline,
|
||||
|
@ -30,10 +87,163 @@ const rules = {
|
|||
plain: (node, output, state) => `${output(node.content, state)}\n\n`,
|
||||
html: (node, output, state) => htmlTag('p', output(node.content, state)),
|
||||
},
|
||||
br: {
|
||||
...defaultRules.br,
|
||||
match: anyScopeRegex(/^ *\n/),
|
||||
plain: () => '\n',
|
||||
},
|
||||
text: {
|
||||
...defaultRules.text,
|
||||
match: anyScopeRegex(/^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]| *\n|\w+:\S|$)/),
|
||||
plain: (node) => node.content,
|
||||
},
|
||||
};
|
||||
|
||||
const markdownRules = {
|
||||
...defaultRules,
|
||||
...plainRules,
|
||||
heading: {
|
||||
...defaultRules.heading,
|
||||
plain: (node, output, state) => {
|
||||
const out = output(node.content, state);
|
||||
if (node.level <= 2) {
|
||||
return `${out}\n${(node.level === 1 ? '=' : '-').repeat(out.length)}\n\n`;
|
||||
}
|
||||
return `${'#'.repeat(node.level)} ${out}\n\n`;
|
||||
},
|
||||
},
|
||||
hr: {
|
||||
...defaultRules.hr,
|
||||
plain: () => '---\n\n',
|
||||
},
|
||||
codeBlock: {
|
||||
...defaultRules.codeBlock,
|
||||
plain: (node) => `\`\`\`${node.lang || ''}\n${node.content}\n\`\`\``,
|
||||
},
|
||||
fence: {
|
||||
...defaultRules.fence,
|
||||
match: blockRegex(/^ *(`{3,}|~{3,}) *(?:(\S+) *)?\n([\s\S]+?)\n?\1 *(?:\n *)*\n/),
|
||||
},
|
||||
blockQuote: {
|
||||
...defaultRules.blockQuote,
|
||||
plain: (node, output, state) => `> ${output(node.content, state).trim().replace(/\n/g, '\n> ')}\n\n`,
|
||||
},
|
||||
list: {
|
||||
...defaultRules.list,
|
||||
plain: (node, output, state) => `${node.items.map((item, i) => {
|
||||
const prefix = node.ordered ? `${node.start + i + 1}. ` : '* ';
|
||||
return prefix + output(item, state).replace(/\n/g, `\n${' '.repeat(prefix.length)}`);
|
||||
}).join('\n')}\n`,
|
||||
},
|
||||
def: undefined,
|
||||
table: {
|
||||
...defaultRules.table,
|
||||
plain: (node, output, state) => {
|
||||
const header = node.header.map((content) => output(content, state));
|
||||
|
||||
function lineWidth(i) {
|
||||
switch (node.align[i]) {
|
||||
case 'left':
|
||||
case 'right':
|
||||
return 2;
|
||||
case 'center':
|
||||
return 3;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
const colWidth = header.map((s, i) => Math.max(s.length, lineWidth(i)));
|
||||
|
||||
const cells = node.cells.map((row) => row.map((content, i) => {
|
||||
const s = output(content, state);
|
||||
if (s.length > colWidth[i]) {
|
||||
colWidth[i] = s.length;
|
||||
}
|
||||
return s;
|
||||
}));
|
||||
|
||||
function pad(s, i) {
|
||||
switch (node.align[i]) {
|
||||
case 'right':
|
||||
return s.padStart(colWidth[i]);
|
||||
case 'center':
|
||||
return s
|
||||
.padStart(s.length + Math.floor((colWidth[i] - s.length) / 2))
|
||||
.padEnd(colWidth[i]);
|
||||
default:
|
||||
return s.padEnd(colWidth[i]);
|
||||
}
|
||||
}
|
||||
|
||||
const line = colWidth.map((len, i) => {
|
||||
switch (node.align[i]) {
|
||||
case 'left':
|
||||
return `:${'-'.repeat(len - 1)}`;
|
||||
case 'center':
|
||||
return `:${'-'.repeat(len - 2)}:`;
|
||||
case 'right':
|
||||
return `${'-'.repeat(len - 1)}:`;
|
||||
default:
|
||||
return '-'.repeat(len);
|
||||
}
|
||||
});
|
||||
|
||||
const table = [
|
||||
header.map(pad),
|
||||
line,
|
||||
...cells.map((row) => row.map(pad))];
|
||||
|
||||
return table.map((row) => `| ${row.join(' | ')} |\n`).join('');
|
||||
},
|
||||
},
|
||||
displayMath: {
|
||||
order: defaultRules.table.order + 0.1,
|
||||
match: blockRegex(/^ *\$\$ *\n?([\s\S]+?)\n?\$\$ *(?:\n *)*\n/),
|
||||
parse: (capture) => ({ content: capture[1] }),
|
||||
plain: (node) => (node.content.includes('\n')
|
||||
? `$$\n${node.content}\n$$\n`
|
||||
: `$$${node.content}$$\n`),
|
||||
html: (node) => mathHtml('div', node),
|
||||
},
|
||||
shrug: {
|
||||
order: defaultRules.escape.order - 0.1,
|
||||
match: inlineRegex(/^¯\\_\(ツ\)_\/¯/),
|
||||
parse: (capture) => ({ type: 'text', content: capture[0] }),
|
||||
},
|
||||
escape: {
|
||||
...defaultRules.escape,
|
||||
plain: (node, output, state) => `\\${output(node.content, state)}`,
|
||||
},
|
||||
tableSeparator: {
|
||||
...defaultRules.tableSeparator,
|
||||
plain: () => ' | ',
|
||||
},
|
||||
link: {
|
||||
...defaultRules.link,
|
||||
plain: (node, output, state) => {
|
||||
const out = output(node.content, state);
|
||||
const target = sanitizeUrl(node.target) || '';
|
||||
if (out !== target || node.title) {
|
||||
return `[${out}](${target}${node.title ? ` "${node.title}"` : ''})`;
|
||||
}
|
||||
return out;
|
||||
},
|
||||
html: (node, output, state) => htmlTag('a', output(node.content, state), {
|
||||
href: sanitizeUrl(node.target) || '',
|
||||
title: node.title,
|
||||
}),
|
||||
},
|
||||
image: {
|
||||
...defaultRules.image,
|
||||
plain: (node) => `![${node.alt}](${sanitizeUrl(node.target) || ''}${node.title ? ` "${node.title}"` : ''})`,
|
||||
html: (node) => htmlTag('img', '', {
|
||||
src: sanitizeUrl(node.target) || '',
|
||||
alt: node.alt,
|
||||
title: node.title,
|
||||
}, false),
|
||||
},
|
||||
reflink: undefined,
|
||||
refimage: undefined,
|
||||
em: {
|
||||
...defaultRules.em,
|
||||
plain: (node, output, state) => `_${output(node.content, state)}_`,
|
||||
|
@ -50,40 +260,59 @@ const rules = {
|
|||
...defaultRules.del,
|
||||
plain: (node, output, state) => `~~${output(node.content, state)}~~`,
|
||||
},
|
||||
inlineCode: {
|
||||
...defaultRules.inlineCode,
|
||||
plain: (node) => `\`${node.content}\``,
|
||||
},
|
||||
spoiler: {
|
||||
order: defaultRules.em.order - 0.5,
|
||||
order: defaultRules.inlineCode.order + 0.1,
|
||||
match: inlineRegex(/^\|\|([\s\S]+?)\|\|(?:\(([\s\S]+?)\))?/),
|
||||
parse: (capture, parse, state) => ({
|
||||
content: parse(capture[1], state),
|
||||
reason: capture[2],
|
||||
}),
|
||||
plain: (node) => `[spoiler${node.reason ? `: ${node.reason}` : ''}](mxc://somewhere)`,
|
||||
html: (node, output, state) => `<span data-mx-spoiler${node.reason ? `="${sanitizeText(node.reason)}"` : ''}>${output(node.content, state)}</span>`,
|
||||
plain: (node, output, state) => `[spoiler${node.reason ? `: ${node.reason}` : ''}](${output(node.content, state)})`,
|
||||
html: (node, output, state) => htmlTag(
|
||||
'span',
|
||||
output(node.content, state),
|
||||
{ 'data-mx-spoiler': node.reason || null },
|
||||
),
|
||||
},
|
||||
inlineMath: {
|
||||
order: defaultRules.del.order + 0.5,
|
||||
order: defaultRules.del.order + 0.2,
|
||||
match: inlineRegex(/^\$(\S[\s\S]+?\S|\S)\$(?!\d)/),
|
||||
parse: (capture) => ({ content: capture[1] }),
|
||||
plain: (node) => `$${node.content}$`,
|
||||
html: (node) => mathHtml('span', node),
|
||||
},
|
||||
br: {
|
||||
...defaultRules.br,
|
||||
match: anyScopeRegex(/^ *\n/),
|
||||
plain: () => '\n',
|
||||
},
|
||||
text: {
|
||||
...defaultRules.text,
|
||||
match: anyScopeRegex(/^[\s\S]+?(?=[^0-9A-Za-z\s\u00c0-\uffff]| *\n|\w+:\S|$)/),
|
||||
plain: (node) => node.content,
|
||||
},
|
||||
};
|
||||
|
||||
function genOut(rules) {
|
||||
const parser = parserFor(rules);
|
||||
|
||||
const plainOutput = outputFor(rules, 'plain');
|
||||
const htmlOutput = outputFor(rules, 'html');
|
||||
const plainOut = outputFor(rules, 'plain');
|
||||
const htmlOut = outputFor(rules, 'html');
|
||||
|
||||
export {
|
||||
parser, plainOutput, htmlOutput,
|
||||
return (source, state) => {
|
||||
let content = parser(source, state);
|
||||
|
||||
if (content.length === 1 && content[0].type === 'paragraph') {
|
||||
content = content[0].content;
|
||||
}
|
||||
|
||||
const plain = plainOut(content, state).trim();
|
||||
const html = htmlOut(content, state);
|
||||
|
||||
const plainHtml = html.replace(/<br>/g, '\n').replace(/<\/p><p>/g, '\n\n').replace(/<\/?p>/g, '');
|
||||
const onlyPlain = sanitizeText(plain) === plainHtml;
|
||||
|
||||
return {
|
||||
onlyPlain,
|
||||
plain,
|
||||
html,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export const plain = genOut(plainRules);
|
||||
export const markdown = genOut(markdownRules);
|
||||
|
|
|
@ -79,6 +79,16 @@ export function parseReply(rawBody) {
|
|||
};
|
||||
}
|
||||
|
||||
export function trimHTMLReply(html) {
|
||||
if (!html) return html;
|
||||
const suffix = '</mx-reply>';
|
||||
const i = html.indexOf(suffix);
|
||||
if (i < 0) {
|
||||
return html;
|
||||
}
|
||||
return html.slice(i + suffix.length);
|
||||
}
|
||||
|
||||
export function hasDMWith(userId) {
|
||||
const mx = initMatrix.matrixClient;
|
||||
const directIds = [...initMatrix.roomList.directs];
|
||||
|
|
Loading…
Reference in a new issue