Merge pull request #3244 from matrix-org/bwindels/diffhtmledits
Show diff for formatted messages in the edit history
This commit is contained in:
commit
ac31b4b8f4
7 changed files with 345 additions and 45 deletions
|
@ -468,7 +468,7 @@ export function bodyToHtml(content, highlights, opts={}) {
|
|||
// their username
|
||||
(
|
||||
content.formatted_body == undefined ||
|
||||
!content.formatted_body.includes("https://matrix.to/")
|
||||
!content.formatted_body.includes("https://matrix.to/")
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -513,3 +513,38 @@ export function linkifyElement(element, options = linkifyMatrix.options) {
|
|||
export function linkifyAndSanitizeHtml(dirtyHtml) {
|
||||
return sanitizeHtml(linkifyString(dirtyHtml), sanitizeHtmlParams);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if a node is a block element or not.
|
||||
* Only takes html nodes into account that are allowed in matrix messages.
|
||||
*
|
||||
* @param {Node} node
|
||||
* @returns {bool}
|
||||
*/
|
||||
export function checkBlockNode(node) {
|
||||
switch (node.nodeName) {
|
||||
case "H1":
|
||||
case "H2":
|
||||
case "H3":
|
||||
case "H4":
|
||||
case "H5":
|
||||
case "H6":
|
||||
case "PRE":
|
||||
case "BLOCKQUOTE":
|
||||
case "DIV":
|
||||
case "P":
|
||||
case "UL":
|
||||
case "OL":
|
||||
case "LI":
|
||||
case "HR":
|
||||
case "TABLE":
|
||||
case "THEAD":
|
||||
case "TBODY":
|
||||
case "TR":
|
||||
case "TH":
|
||||
case "TD":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import * as HtmlUtils from '../../../HtmlUtils';
|
||||
import { editBodyDiffToHtml } from '../../../utils/MessageDiffUtils';
|
||||
import {formatTime} from '../../../DateUtils';
|
||||
import {MatrixEvent} from 'matrix-js-sdk';
|
||||
import {pillifyLinks} from '../../../utils/pillify';
|
||||
|
@ -25,18 +26,12 @@ import sdk from '../../../index';
|
|||
import MatrixClientPeg from '../../../MatrixClientPeg';
|
||||
import Modal from '../../../Modal';
|
||||
import classNames from 'classnames';
|
||||
import DiffMatchPatch from 'diff-match-patch';
|
||||
|
||||
function getReplacedContent(event) {
|
||||
const originalContent = event.getOriginalContent();
|
||||
return originalContent["m.new_content"] || originalContent;
|
||||
}
|
||||
|
||||
function isPlainMessage(event) {
|
||||
const content = getReplacedContent(event);
|
||||
return content.msgtype === "m.text" && !content.format;
|
||||
}
|
||||
|
||||
export default class EditHistoryMessage extends React.PureComponent {
|
||||
static propTypes = {
|
||||
// the message event being edited
|
||||
|
@ -128,22 +123,6 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
);
|
||||
}
|
||||
|
||||
_renderBodyDiff(oldBody, newBody) {
|
||||
const dpm = new DiffMatchPatch();
|
||||
const diff = dpm.diff_main(oldBody, newBody);
|
||||
dpm.diff_cleanupSemantic(diff);
|
||||
return diff.map(([modifier, text], i) => {
|
||||
// not using del and ins tags here as del is used for strikethrough
|
||||
if (modifier < 0) {
|
||||
return (<span className="mx_EditHistoryMessage_deletion" key={i}>{text}</span>);
|
||||
} else if (modifier > 0) {
|
||||
return (<span className="mx_EditHistoryMessage_insertion" key={i}>{text}</span>);
|
||||
} else {
|
||||
return text;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const {mxEvent} = this.props;
|
||||
const content = getReplacedContent(mxEvent);
|
||||
|
@ -153,8 +132,8 @@ export default class EditHistoryMessage extends React.PureComponent {
|
|||
contentContainer = <UnknownBody mxEvent={this.props.mxEvent} />;
|
||||
} else {
|
||||
let contentElements;
|
||||
if (isPlainMessage(mxEvent) && this.props.previousEdit && isPlainMessage(this.props.previousEdit)) {
|
||||
contentElements = this._renderBodyDiff(getReplacedContent(this.props.previousEdit).body, content.body);
|
||||
if (this.props.previousEdit) {
|
||||
contentElements = editBodyDiffToHtml(getReplacedContent(this.props.previousEdit), content);
|
||||
} else {
|
||||
contentElements = HtmlUtils.bodyToHtml(content, null, {stripReplyFallback: true});
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ limitations under the License.
|
|||
|
||||
import { MATRIXTO_URL_PATTERN } from '../linkify-matrix';
|
||||
import { walkDOMDepthFirst } from "./dom";
|
||||
import { checkBlockNode } from "../HtmlUtils";
|
||||
|
||||
const REGEX_MATRIXTO = new RegExp(MATRIXTO_URL_PATTERN);
|
||||
|
||||
|
@ -118,21 +119,6 @@ function checkDecendInto(node) {
|
|||
}
|
||||
}
|
||||
|
||||
function checkBlockNode(node) {
|
||||
switch (node.nodeName) {
|
||||
case "PRE":
|
||||
case "BLOCKQUOTE":
|
||||
case "DIV":
|
||||
case "P":
|
||||
case "UL":
|
||||
case "OL":
|
||||
case "LI":
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function checkIgnored(n) {
|
||||
if (n.nodeType === Node.TEXT_NODE) {
|
||||
// riot adds \n text nodes in a lot of places,
|
||||
|
|
258
src/utils/MessageDiffUtils.js
Normal file
258
src/utils/MessageDiffUtils.js
Normal file
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import DiffMatchPatch from 'diff-match-patch';
|
||||
import {DiffDOM} from "diff-dom";
|
||||
import { checkBlockNode, bodyToHtml } from "../HtmlUtils";
|
||||
|
||||
const decodeEntities = (function() {
|
||||
let textarea = null;
|
||||
return function(string) {
|
||||
if (!textarea) {
|
||||
textarea = document.createElement("textarea");
|
||||
}
|
||||
textarea.innerHTML = string;
|
||||
return textarea.value;
|
||||
};
|
||||
})();
|
||||
|
||||
function textToHtml(text) {
|
||||
const container = document.createElement("div");
|
||||
container.textContent = text;
|
||||
return container.innerHTML;
|
||||
}
|
||||
|
||||
function getSanitizedHtmlBody(content) {
|
||||
const opts = {
|
||||
stripReplyFallback: true,
|
||||
returnString: true,
|
||||
};
|
||||
if (content.format === "org.matrix.custom.html") {
|
||||
return bodyToHtml(content, null, opts);
|
||||
} else {
|
||||
// convert the string to something that can be safely
|
||||
// embedded in an html document, e.g. use html entities where needed
|
||||
// This is also needed so that DiffDOM wouldn't interpret something
|
||||
// as a tag when somebody types e.g. "</sarcasm>"
|
||||
|
||||
// as opposed to bodyToHtml, here we also render
|
||||
// text messages with dangerouslySetInnerHTML, to unify
|
||||
// the code paths and because we need html to show differences
|
||||
return textToHtml(bodyToHtml(content, null, opts));
|
||||
}
|
||||
}
|
||||
|
||||
function wrapInsertion(child) {
|
||||
const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span");
|
||||
wrapper.className = "mx_EditHistoryMessage_insertion";
|
||||
wrapper.appendChild(child);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function wrapDeletion(child) {
|
||||
const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span");
|
||||
wrapper.className = "mx_EditHistoryMessage_deletion";
|
||||
wrapper.appendChild(child);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
function findRefNodes(root, route, isAddition) {
|
||||
let refNode = root;
|
||||
let refParentNode;
|
||||
const end = isAddition ? route.length - 1 : route.length;
|
||||
for (let i = 0; i < end; ++i) {
|
||||
refParentNode = refNode;
|
||||
refNode = refNode.childNodes[route[i]];
|
||||
}
|
||||
return {refNode, refParentNode};
|
||||
}
|
||||
|
||||
function diffTreeToDOM(desc) {
|
||||
if (desc.nodeName === "#text") {
|
||||
return stringAsTextNode(desc.data);
|
||||
} else {
|
||||
const node = document.createElement(desc.nodeName);
|
||||
if (desc.attributes) {
|
||||
for (const [key, value] of Object.entries(desc.attributes)) {
|
||||
node.setAttribute(key, value);
|
||||
}
|
||||
}
|
||||
if (desc.childNodes) {
|
||||
for (const childDesc of desc.childNodes) {
|
||||
node.appendChild(diffTreeToDOM(childDesc));
|
||||
}
|
||||
}
|
||||
return node;
|
||||
}
|
||||
}
|
||||
|
||||
function insertBefore(parent, nextSibling, child) {
|
||||
if (nextSibling) {
|
||||
parent.insertBefore(child, nextSibling);
|
||||
} else {
|
||||
parent.appendChild(child);
|
||||
}
|
||||
}
|
||||
|
||||
function isRouteOfNextSibling(route1, route2) {
|
||||
// routes are arrays with indices,
|
||||
// to be interpreted as a path in the dom tree
|
||||
|
||||
// ensure same parent
|
||||
for (let i = 0; i < route1.length - 1; ++i) {
|
||||
if (route1[i] !== route2[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// the route2 is only affected by the diff of route1
|
||||
// inserting an element if the index at the level of the
|
||||
// last element of route1 being larger
|
||||
// (e.g. coming behind route1 at that level)
|
||||
const lastD1Idx = route1.length - 1;
|
||||
return route2[lastD1Idx] >= route1[lastD1Idx];
|
||||
}
|
||||
|
||||
function adjustRoutes(diff, remainingDiffs) {
|
||||
if (diff.action === "removeTextElement" || diff.action === "removeElement") {
|
||||
// as removed text is not removed from the html, but marked as deleted,
|
||||
// we need to readjust indices that assume the current node has been removed.
|
||||
const advance = 1;
|
||||
for (const rd of remainingDiffs) {
|
||||
if (isRouteOfNextSibling(diff.route, rd.route)) {
|
||||
rd.route[diff.route.length - 1] += advance;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stringAsTextNode(string) {
|
||||
return document.createTextNode(decodeEntities(string));
|
||||
}
|
||||
|
||||
function renderDifferenceInDOM(originalRootNode, diff, diffMathPatch) {
|
||||
const {refNode, refParentNode} = findRefNodes(originalRootNode, diff.route);
|
||||
switch (diff.action) {
|
||||
case "replaceElement": {
|
||||
const container = document.createElement("span");
|
||||
const delNode = wrapDeletion(diffTreeToDOM(diff.oldValue));
|
||||
const insNode = wrapInsertion(diffTreeToDOM(diff.newValue));
|
||||
container.appendChild(delNode);
|
||||
container.appendChild(insNode);
|
||||
refNode.parentNode.replaceChild(container, refNode);
|
||||
break;
|
||||
}
|
||||
case "removeTextElement": {
|
||||
const delNode = wrapDeletion(stringAsTextNode(diff.value));
|
||||
refNode.parentNode.replaceChild(delNode, refNode);
|
||||
break;
|
||||
}
|
||||
case "removeElement": {
|
||||
const delNode = wrapDeletion(diffTreeToDOM(diff.element));
|
||||
refNode.parentNode.replaceChild(delNode, refNode);
|
||||
break;
|
||||
}
|
||||
case "modifyTextElement": {
|
||||
const textDiffs = diffMathPatch.diff_main(diff.oldValue, diff.newValue);
|
||||
diffMathPatch.diff_cleanupSemantic(textDiffs);
|
||||
const container = document.createElement("span");
|
||||
for (const [modifier, text] of textDiffs) {
|
||||
let textDiffNode = stringAsTextNode(text);
|
||||
if (modifier < 0) {
|
||||
textDiffNode = wrapDeletion(textDiffNode);
|
||||
} else if (modifier > 0) {
|
||||
textDiffNode = wrapInsertion(textDiffNode);
|
||||
}
|
||||
container.appendChild(textDiffNode);
|
||||
}
|
||||
refNode.parentNode.replaceChild(container, refNode);
|
||||
break;
|
||||
}
|
||||
case "addElement": {
|
||||
const insNode = wrapInsertion(diffTreeToDOM(diff.element));
|
||||
insertBefore(refParentNode, refNode, insNode);
|
||||
break;
|
||||
}
|
||||
case "addTextElement": {
|
||||
if (diff.value !== "\n") {
|
||||
const insNode = wrapInsertion(stringAsTextNode(diff.value));
|
||||
insertBefore(refParentNode, refNode, insNode);
|
||||
}
|
||||
break;
|
||||
}
|
||||
// e.g. when changing a the href of a link,
|
||||
// show the link with old href as removed and with the new href as added
|
||||
case "removeAttribute":
|
||||
case "addAttribute":
|
||||
case "modifyAttribute": {
|
||||
const delNode = wrapDeletion(refNode.cloneNode(true));
|
||||
const updatedNode = refNode.cloneNode(true);
|
||||
if (diff.action === "addAttribute" || diff.action === "modifyAttribute") {
|
||||
updatedNode.setAttribute(diff.name, diff.newValue);
|
||||
} else {
|
||||
updatedNode.removeAttribute(diff.name);
|
||||
}
|
||||
const insNode = wrapInsertion(updatedNode);
|
||||
const container = document.createElement(checkBlockNode(refNode) ? "div" : "span");
|
||||
container.appendChild(delNode);
|
||||
container.appendChild(insNode);
|
||||
refNode.parentNode.replaceChild(container, refNode);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
// Should not happen (modifyComment, ???)
|
||||
console.warn("MessageDiffUtils::editBodyDiffToHtml: diff action not supported atm", diff);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a message with the changes made in an edit shown visually.
|
||||
* @param {object} originalContent the content for the base message
|
||||
* @param {object} editContent the content for the edit message
|
||||
* @return {object} a react element similar to what `bodyToHtml` returns
|
||||
*/
|
||||
export function editBodyDiffToHtml(originalContent, editContent) {
|
||||
// wrap the body in a div, DiffDOM needs a root element
|
||||
const originalBody = `<div>${getSanitizedHtmlBody(originalContent)}</div>`;
|
||||
const editBody = `<div>${getSanitizedHtmlBody(editContent)}</div>`;
|
||||
const dd = new DiffDOM();
|
||||
// diffActions is an array of objects with at least a `action` and `route`
|
||||
// property. `action` tells us what the diff object changes, and `route` where.
|
||||
// `route` is a path on the DOM tree expressed as an array of indices.
|
||||
const diffActions = dd.diff(originalBody, editBody);
|
||||
// for diffing text fragments
|
||||
const diffMathPatch = new DiffMatchPatch();
|
||||
// parse the base html message as a DOM tree, to which we'll apply the differences found.
|
||||
// fish out the div in which we wrapped the messages above with children[0].
|
||||
const originalRootNode = new DOMParser().parseFromString(originalBody, "text/html").body.children[0];
|
||||
for (let i = 0; i < diffActions.length; ++i) {
|
||||
const diff = diffActions[i];
|
||||
renderDifferenceInDOM(originalRootNode, diff, diffMathPatch);
|
||||
// DiffDOM assumes in subsequent diffs route path that
|
||||
// the action was applied (e.g. that a removeElement action removed the element).
|
||||
// This is not the case for us. We render differences in the DOM tree, and don't apply them.
|
||||
// So we need to adjust the routes of the remaining diffs to account for this.
|
||||
adjustRoutes(diff, diffActions.slice(i + 1));
|
||||
}
|
||||
// take the html out of the modified DOM tree again
|
||||
const safeBody = originalRootNode.innerHTML;
|
||||
const className = classNames({
|
||||
'mx_EventTile_body': true,
|
||||
'markdown-body': true,
|
||||
});
|
||||
return <span key="body" className={className} dangerouslySetInnerHTML={{ __html: safeBody }} dir="auto" />;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue