From 15c1f6dadf2ade072ffd6fb8ad51781953de88d8 Mon Sep 17 00:00:00 2001 From: ginnyTheCat Date: Wed, 14 Sep 2022 11:00:06 +0200 Subject: [PATCH] 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 --- src/app/atoms/math/Math.jsx | 1 + src/app/atoms/math/Math.scss | 3 + src/app/molecules/message/Message.jsx | 15 +- src/app/molecules/message/Message.scss | 6 +- src/app/organisms/room/RoomViewInput.jsx | 6 +- src/client/action/navigation.js | 3 +- src/client/state/RoomsInput.js | 247 ++++++------------- src/client/state/navigation.js | 1 + src/util/markdown.js | 287 ++++++++++++++++++++--- src/util/matrixUtil.js | 10 + 10 files changed, 368 insertions(+), 211 deletions(-) create mode 100644 src/app/atoms/math/Math.scss diff --git a/src/app/atoms/math/Math.jsx b/src/app/atoms/math/Math.jsx index 87f8589..ab52a47 100644 --- a/src/app/atoms/math/Math.jsx +++ b/src/app/atoms/math/Math.jsx @@ -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'; diff --git a/src/app/atoms/math/Math.scss b/src/app/atoms/math/Math.scss new file mode 100644 index 0000000..306b147 --- /dev/null +++ b/src/app/atoms/math/Math.scss @@ -0,0 +1,3 @@ +.katex-display { + margin: 0 !important; +} diff --git a/src/app/molecules/message/Message.jsx b/src/app/molecules/message/Message.jsx index ab05e0e..02a5562 100644 --- a/src/app/molecules/message/Message.jsx +++ b/src/app/molecules/message/Message.jsx @@ -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

element (automatically applying // white-space: pre-wrap) in order to preserve newlines - content = (

{content}

); + content = (

{content}

); } 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 = ''; diff --git a/src/app/molecules/message/Message.scss b/src/app/molecules/message/Message.scss index 66d0c7e..5dda9c9 100644 --- a/src/app/molecules/message/Message.scss +++ b/src/app/molecules/message/Message.scss @@ -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] { diff --git a/src/app/organisms/room/RoomViewInput.jsx b/src/app/organisms/room/RoomViewInput.jsx index de72e2b..c43eb60 100644 --- a/src/app/organisms/room/RoomViewInput.jsx +++ b/src/app/organisms/room/RoomViewInput.jsx @@ -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(); } diff --git a/src/client/action/navigation.js b/src/client/action/navigation.js index 1292d56..4ee78a6 100644 --- a/src/client/action/navigation.js +++ b/src/client/action/navigation.js @@ -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, }); } diff --git a/src/client/state/RoomsInput.js b/src/client/state/RoomsInput.js index e677871..fb4b6c3 100644 --- a/src/client/state/RoomsInput.js +++ b/src/client/state/RoomsInput.js @@ -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 = `In reply to`; - const userLink = `${reply.userId}`; - const formattedReply = getFormattedBody(reply.body.replace(/\n/g, '\n> ')); - return `
${replyToLink}${userLink}
${formattedReply}
`; -} - -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) => ( - `@${userIdsToDisplayNames[match[0]]}` - ), - ); -} - -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 = `:${
-          emoji.shortcode
-        }:`; - } 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('')); + if (fReplyHead) content.formatted_body = `${fReplyHead}${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 = `In reply to`; + const userLink = `${sanitizeText(reply.userId)}`; + const fallback = `
${replyToLink}${userLink}
${reply.formattedBody || sanitizeText(reply.body)}
`; + 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('')); - - content.format = 'org.matrix.custom.html'; - content.formatted_body = `${fReplyHead}${(content.formatted_body || content.body)}`; - - content.body = `${replyHead}\n\n${content.body}`; - } - this.matrixClient.sendMessage(roomId, content); } } diff --git a/src/client/state/navigation.js b/src/client/state/navigation.js index 7b13dd1..07231cd 100644 --- a/src/client/state/navigation.js +++ b/src/client/state/navigation.js @@ -375,6 +375,7 @@ class Navigation extends EventEmitter { action.userId, action.eventId, action.body, + action.formattedBody, ); }, [cons.actions.navigation.OPEN_SEARCH]: () => { diff --git a/src/util/markdown.js b/src/util/markdown.js index 2e4f53d..324a12b 100644 --- a/src/util/markdown.js +++ b/src/util/markdown.js @@ -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}`; +} + 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) => `${output(node.content, state)}`, + 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, - }, }; -const parser = parserFor(rules); +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(/
/g, '\n').replace(/<\/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); diff --git a/src/util/matrixUtil.js b/src/util/matrixUtil.js index ef016ed..54ee31b 100644 --- a/src/util/matrixUtil.js +++ b/src/util/matrixUtil.js @@ -79,6 +79,16 @@ export function parseReply(rawBody) { }; } +export function trimHTMLReply(html) { + if (!html) return html; + const suffix = ''; + 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];