Allow rendering messages as plaintext (#805)
authorginnyTheCat <ginnythecat@lelux.net>
Wed, 14 Sep 2022 09:00:06 +0000 (11:00 +0200)
committerGitHub <noreply@github.com>
Wed, 14 Sep 2022 09:00:06 +0000 (14:30 +0530)
* 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
src/app/atoms/math/Math.scss [new file with mode: 0644]
src/app/molecules/message/Message.jsx
src/app/molecules/message/Message.scss
src/app/organisms/room/RoomViewInput.jsx
src/client/action/navigation.js
src/client/state/RoomsInput.js
src/client/state/navigation.js
src/util/markdown.js
src/util/matrixUtil.js

index 87f858999bced6bf5b23e0467aa88a193e39039b..ab52a47847d8f282f26e14fc40f122f1596aded4 100644 (file)
@@ -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 (file)
index 0000000..306b147
--- /dev/null
@@ -0,0 +1,3 @@
+.katex-display {
+  margin: 0 !important;
+}
index ab05e0e885984685b8ce48888a005b93f3bc2d7a..02a5562c1651df3a13b88f9771abf7200e18a934 100644 (file)
@@ -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 = '';
index 66d0c7ec49c0ca745522f3879e5f89cec960bc92..5dda9c98d7cc5f8f40d14d48c0c114081628795d 100644 (file)
 .message__body {
   word-break: break-word;
 
-  & > .text > * {
+  & > .text > .message__body-plain {
     white-space: pre-wrap;
   }
 
     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] {
index de72e2bb1876f18ad6d07e6b47d03cc6762d366f..c43eb601619b460190a6aef30c9c154a93e3010c 100644 (file)
@@ -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();
   }
 
index 1292d56dfba92a92078f4aba98a90843298b7709..4ee78a638cdf40a75584b040ec0aea8e89231134 100644 (file)
@@ -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,
   });
 }
 
index e6778711c3370ebffcdf2ffbfdd75d1e1d2da2a4..fb4b6c31bc8bc68e082d38fb6ab15014d3ec2d23 100644 (file)
@@ -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);
   }
 }
index 7b13dd187c56d7e39c7c1006c2f7924fd829f138..07231cd4be43e223b2a1d631e4ed70f1accb6b9a 100644 (file)
@@ -375,6 +375,7 @@ class Navigation extends EventEmitter {
           action.userId,
           action.eventId,
           action.body,
+          action.formattedBody,
         );
       },
       [cons.actions.navigation.OPEN_SEARCH]: () => {
index 2e4f53d000976703dfec582d577d688f6a127edc..324a12b55a5f51e5a9be42a46a5799102505bd31 100644 (file)
@@ -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,
-  },
 };
 
-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(/<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);
index ef016edab4be662a03009a23776013db72f96aa2..54ee31bbba878b294950ef0dbe3aa12faa650713 100644 (file)
@@ -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];