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 React, { useEffect, useRef } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
|
import './Math.scss';
|
||||||
|
|
||||||
import katex from 'katex';
|
import katex from 'katex';
|
||||||
import 'katex/dist/katex.min.css';
|
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 { twemojify } from '../../../util/twemojify';
|
||||||
|
|
||||||
import initMatrix from '../../../client/initMatrix';
|
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 colorMXID from '../../../util/colorMXID';
|
||||||
import { getEventCords } from '../../../util/common';
|
import { getEventCords } from '../../../util/common';
|
||||||
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
import { redactEvent, sendReaction } from '../../../client/action/roomTimeline';
|
||||||
|
@ -248,7 +250,7 @@ const MessageBody = React.memo(({
|
||||||
if (!isCustomHTML) {
|
if (!isCustomHTML) {
|
||||||
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
// If this is a plaintext message, wrap it in a <p> element (automatically applying
|
||||||
// white-space: pre-wrap) in order to preserve newlines
|
// white-space: pre-wrap) in order to preserve newlines
|
||||||
content = (<p>{content}</p>);
|
content = (<p className="message__body-plain">{content}</p>);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
@ -729,23 +731,23 @@ function Message({
|
||||||
let { body } = content;
|
let { body } = content;
|
||||||
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
const username = mEvent.sender ? getUsernameOfRoomMember(mEvent.sender) : getUsername(senderId);
|
||||||
const avatarSrc = mEvent.sender?.getAvatarUrl(initMatrix.matrixClient.baseUrl, 36, 36, 'crop') ?? null;
|
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(() => {
|
const edit = useCallback(() => {
|
||||||
setEdit(eventId);
|
setEdit(eventId);
|
||||||
}, []);
|
}, []);
|
||||||
const reply = useCallback(() => {
|
const reply = useCallback(() => {
|
||||||
replyTo(senderId, mEvent.getId(), body);
|
replyTo(senderId, mEvent.getId(), body, customHTML);
|
||||||
}, [body]);
|
}, [body, customHTML]);
|
||||||
|
|
||||||
if (msgType === 'm.emote') className.push('message--type-emote');
|
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 isEdited = roomTimeline ? editedTimeline.has(eventId) : false;
|
||||||
const haveReactions = roomTimeline
|
const haveReactions = roomTimeline
|
||||||
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
? reactionTimeline.has(eventId) || !!mEvent.getServerAggregatedRelation('m.annotation')
|
||||||
: false;
|
: false;
|
||||||
const isReply = !!mEvent.replyEventId;
|
const isReply = !!mEvent.replyEventId;
|
||||||
let customHTML = isCustomHTML ? content.formatted_body : null;
|
|
||||||
|
|
||||||
if (isEdited) {
|
if (isEdited) {
|
||||||
const editedList = editedTimeline.get(eventId);
|
const editedList = editedTimeline.get(eventId);
|
||||||
|
@ -755,6 +757,7 @@ function Message({
|
||||||
|
|
||||||
if (isReply) {
|
if (isReply) {
|
||||||
body = parseReply(body)?.body ?? body;
|
body = parseReply(body)?.body ?? body;
|
||||||
|
customHTML = trimHTMLReply(customHTML);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof body !== 'string') body = '';
|
if (typeof body !== 'string') body = '';
|
||||||
|
|
|
@ -163,7 +163,7 @@
|
||||||
.message__body {
|
.message__body {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
||||||
& > .text > * {
|
& > .text > .message__body-plain {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,8 +174,8 @@
|
||||||
white-space: initial !important;
|
white-space: initial !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
& p:not(:last-child) {
|
& > .text > p + p {
|
||||||
margin-bottom: var(--sp-normal);
|
margin-top: var(--sp-normal);
|
||||||
}
|
}
|
||||||
|
|
||||||
& span[data-mx-pill] {
|
& span[data-mx-pill] {
|
||||||
|
|
|
@ -143,9 +143,11 @@ function RoomViewInput({
|
||||||
textAreaRef.current.focus();
|
textAreaRef.current.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setUpReply(userId, eventId, body) {
|
function setUpReply(userId, eventId, body, formattedBody) {
|
||||||
setReplyTo({ userId, eventId, body });
|
setReplyTo({ userId, eventId, body });
|
||||||
roomsInput.setReplyTo(roomId, { userId, eventId, body });
|
roomsInput.setReplyTo(roomId, {
|
||||||
|
userId, eventId, body, formattedBody,
|
||||||
|
});
|
||||||
focusInput();
|
focusInput();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -139,12 +139,13 @@ export function openViewSource(event) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function replyTo(userId, eventId, body) {
|
export function replyTo(userId, eventId, body, formattedBody) {
|
||||||
appDispatcher.dispatch({
|
appDispatcher.dispatch({
|
||||||
type: cons.actions.navigation.CLICK_REPLY_TO,
|
type: cons.actions.navigation.CLICK_REPLY_TO,
|
||||||
userId,
|
userId,
|
||||||
eventId,
|
eventId,
|
||||||
body,
|
body,
|
||||||
|
formattedBody,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -6,11 +6,9 @@ import { getBlobSafeMimeType } from '../../util/mimetypes';
|
||||||
import { sanitizeText } from '../../util/sanitize';
|
import { sanitizeText } from '../../util/sanitize';
|
||||||
import cons from './cons';
|
import cons from './cons';
|
||||||
import settings from './settings';
|
import settings from './settings';
|
||||||
import { htmlOutput, parser } from '../../util/markdown';
|
import { markdown, plain } from '../../util/markdown';
|
||||||
|
|
||||||
const blurhashField = 'xyz.amorgan.blurhash';
|
const blurhashField = 'xyz.amorgan.blurhash';
|
||||||
const MXID_REGEX = /\B@\S+:\S+\.\S+[^.,:;?!\s]/g;
|
|
||||||
const SHORTCODE_REGEX = /\B:([\w-]+):\B/g;
|
|
||||||
|
|
||||||
function encodeBlurhash(img) {
|
function encodeBlurhash(img) {
|
||||||
const canvas = document.createElement('canvas');
|
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 {
|
class RoomsInput extends EventEmitter {
|
||||||
constructor(mx, roomList) {
|
constructor(mx, roomList) {
|
||||||
super();
|
super();
|
||||||
|
@ -274,9 +187,76 @@ class RoomsInput extends EventEmitter {
|
||||||
return this.roomIdToInput.get(roomId)?.isSending || false;
|
return this.roomIdToInput.get(roomId)?.isSending || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendInput(roomId, options) {
|
getContent(roomId, options, message, reply, edit) {
|
||||||
const { msgType, autoMarkdown } = options;
|
const msgType = options?.msgType || 'm.text';
|
||||||
|
const autoMarkdown = options?.autoMarkdown ?? true;
|
||||||
|
|
||||||
const room = this.matrixClient.getRoom(roomId);
|
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);
|
const input = this.getInput(roomId);
|
||||||
input.isSending = true;
|
input.isSending = true;
|
||||||
this.roomIdToInput.set(roomId, input);
|
this.roomIdToInput.set(roomId, input);
|
||||||
|
@ -286,38 +266,7 @@ class RoomsInput extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.getMessage(roomId).trim() !== '') {
|
if (this.getMessage(roomId).trim() !== '') {
|
||||||
const rawMessage = input.message;
|
const content = this.getContent(roomId, options, input.message, input.replyTo);
|
||||||
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);
|
|
||||||
}
|
|
||||||
this.matrixClient.sendMessage(roomId, content);
|
this.matrixClient.sendMessage(roomId, content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -460,55 +409,13 @@ class RoomsInput extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEditedMessage(roomId, mEvent, editedBody) {
|
async sendEditedMessage(roomId, mEvent, editedBody) {
|
||||||
const room = this.matrixClient.getRoom(roomId);
|
const content = this.getContent(
|
||||||
const isReply = typeof mEvent.getWireContent()['m.relates_to']?.['m.in_reply_to'] !== 'undefined';
|
roomId,
|
||||||
|
{ msgType: mEvent.getWireContent().msgtype },
|
||||||
const msgtype = mEvent.getWireContent().msgtype ?? 'm.text';
|
editedBody,
|
||||||
|
null,
|
||||||
const content = {
|
mEvent,
|
||||||
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]]}`,
|
|
||||||
);
|
);
|
||||||
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);
|
this.matrixClient.sendMessage(roomId, content);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -375,6 +375,7 @@ class Navigation extends EventEmitter {
|
||||||
action.userId,
|
action.userId,
|
||||||
action.eventId,
|
action.eventId,
|
||||||
action.body,
|
action.body,
|
||||||
|
action.formattedBody,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[cons.actions.navigation.OPEN_SEARCH]: () => {
|
[cons.actions.navigation.OPEN_SEARCH]: () => {
|
||||||
|
|
|
@ -1,25 +1,82 @@
|
||||||
import SimpleMarkdown from '@khanacademy/simple-markdown';
|
import SimpleMarkdown from '@khanacademy/simple-markdown';
|
||||||
|
|
||||||
const {
|
const {
|
||||||
defaultRules, parserFor, outputFor, anyScopeRegex, blockRegex, inlineRegex, htmlTag, sanitizeText,
|
defaultRules, parserFor, outputFor, anyScopeRegex, blockRegex, inlineRegex,
|
||||||
|
sanitizeText, sanitizeUrl,
|
||||||
} = SimpleMarkdown;
|
} = 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) {
|
function mathHtml(wrap, node) {
|
||||||
return htmlTag(wrap, htmlTag('code', sanitizeText(node.content)), { 'data-mx-maths': node.content });
|
return htmlTag(wrap, htmlTag('code', sanitizeText(node.content)), { 'data-mx-maths': node.content });
|
||||||
}
|
}
|
||||||
|
|
||||||
const rules = {
|
const emojiRegex = /^:([\w-]+):/;
|
||||||
...defaultRules,
|
|
||||||
|
const plainRules = {
|
||||||
Array: {
|
Array: {
|
||||||
...defaultRules.Array,
|
...defaultRules.Array,
|
||||||
plain: (arr, output, state) => arr.map((node) => output(node, state)).join(''),
|
plain: (arr, output, state) => arr.map((node) => output(node, state)).join(''),
|
||||||
},
|
},
|
||||||
displayMath: {
|
userMention: {
|
||||||
order: defaultRules.list.order + 0.5,
|
order: defaultRules.em.order - 0.9,
|
||||||
match: blockRegex(/^\$\$\n*([\s\S]+?)\n*\$\$/),
|
match: inlineRegex(/^(@\S+:\S+)/),
|
||||||
parse: (capture) => ({ content: capture[1] }),
|
parse: (capture, _, state) => ({
|
||||||
plain: (node) => `$$\n${node.content}\n$$`,
|
content: state.userNames[capture[1]] ? `@${state.userNames[capture[1]]}` : capture[1],
|
||||||
html: (node) => mathHtml('div', node),
|
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: {
|
newline: {
|
||||||
...defaultRules.newline,
|
...defaultRules.newline,
|
||||||
|
@ -30,10 +87,163 @@ const rules = {
|
||||||
plain: (node, output, state) => `${output(node.content, state)}\n\n`,
|
plain: (node, output, state) => `${output(node.content, state)}\n\n`,
|
||||||
html: (node, output, state) => htmlTag('p', output(node.content, state)),
|
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: {
|
escape: {
|
||||||
...defaultRules.escape,
|
...defaultRules.escape,
|
||||||
plain: (node, output, state) => `\\${output(node.content, state)}`,
|
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: {
|
em: {
|
||||||
...defaultRules.em,
|
...defaultRules.em,
|
||||||
plain: (node, output, state) => `_${output(node.content, state)}_`,
|
plain: (node, output, state) => `_${output(node.content, state)}_`,
|
||||||
|
@ -50,40 +260,59 @@ const rules = {
|
||||||
...defaultRules.del,
|
...defaultRules.del,
|
||||||
plain: (node, output, state) => `~~${output(node.content, state)}~~`,
|
plain: (node, output, state) => `~~${output(node.content, state)}~~`,
|
||||||
},
|
},
|
||||||
|
inlineCode: {
|
||||||
|
...defaultRules.inlineCode,
|
||||||
|
plain: (node) => `\`${node.content}\``,
|
||||||
|
},
|
||||||
spoiler: {
|
spoiler: {
|
||||||
order: defaultRules.em.order - 0.5,
|
order: defaultRules.inlineCode.order + 0.1,
|
||||||
match: inlineRegex(/^\|\|([\s\S]+?)\|\|(?:\(([\s\S]+?)\))?/),
|
match: inlineRegex(/^\|\|([\s\S]+?)\|\|(?:\(([\s\S]+?)\))?/),
|
||||||
parse: (capture, parse, state) => ({
|
parse: (capture, parse, state) => ({
|
||||||
content: parse(capture[1], state),
|
content: parse(capture[1], state),
|
||||||
reason: capture[2],
|
reason: capture[2],
|
||||||
}),
|
}),
|
||||||
plain: (node) => `[spoiler${node.reason ? `: ${node.reason}` : ''}](mxc://somewhere)`,
|
plain: (node, output, state) => `[spoiler${node.reason ? `: ${node.reason}` : ''}](${output(node.content, state)})`,
|
||||||
html: (node, output, state) => `<span data-mx-spoiler${node.reason ? `="${sanitizeText(node.reason)}"` : ''}>${output(node.content, state)}</span>`,
|
html: (node, output, state) => htmlTag(
|
||||||
|
'span',
|
||||||
|
output(node.content, state),
|
||||||
|
{ 'data-mx-spoiler': node.reason || null },
|
||||||
|
),
|
||||||
},
|
},
|
||||||
inlineMath: {
|
inlineMath: {
|
||||||
order: defaultRules.del.order + 0.5,
|
order: defaultRules.del.order + 0.2,
|
||||||
match: inlineRegex(/^\$(\S[\s\S]+?\S|\S)\$(?!\d)/),
|
match: inlineRegex(/^\$(\S[\s\S]+?\S|\S)\$(?!\d)/),
|
||||||
parse: (capture) => ({ content: capture[1] }),
|
parse: (capture) => ({ content: capture[1] }),
|
||||||
plain: (node) => `$${node.content}$`,
|
plain: (node) => `$${node.content}$`,
|
||||||
html: (node) => mathHtml('span', node),
|
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 parser = parserFor(rules);
|
||||||
|
|
||||||
const plainOutput = outputFor(rules, 'plain');
|
const plainOut = outputFor(rules, 'plain');
|
||||||
const htmlOutput = outputFor(rules, 'html');
|
const htmlOut = outputFor(rules, 'html');
|
||||||
|
|
||||||
export {
|
return (source, state) => {
|
||||||
parser, plainOutput, htmlOutput,
|
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) {
|
export function hasDMWith(userId) {
|
||||||
const mx = initMatrix.matrixClient;
|
const mx = initMatrix.matrixClient;
|
||||||
const directIds = [...initMatrix.roomList.directs];
|
const directIds = [...initMatrix.roomList.directs];
|
||||||
|
|
Loading…
Reference in a new issue