/* Copyright 2024 New Vector Ltd. Copyright 2019-2021 , 2023 The Matrix.org Foundation C.I.C. SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only Please see LICENSE files in the repository root for full details. */ import React from "react"; import classNames from "classnames"; import DiffMatchPatch from "diff-match-patch"; import { DiffDOM, IDiff } from "diff-dom"; import { IContent } from "matrix-js-sdk/src/matrix"; import { logger } from "matrix-js-sdk/src/logger"; import { unescape } from "lodash"; import { bodyToHtml, checkBlockNode, EventRenderOpts } from "../HtmlUtils"; function textToHtml(text: string): string { const container = document.createElement("div"); container.textContent = text; return container.innerHTML; } function getSanitizedHtmlBody(content: IContent): string { const opts: EventRenderOpts = { stripReplyFallback: 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. "" // 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: Node): HTMLElement { const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span"); wrapper.className = "mx_EditHistoryMessage_insertion"; wrapper.appendChild(child); return wrapper; } function wrapDeletion(child: Node): HTMLElement { const wrapper = document.createElement(checkBlockNode(child) ? "div" : "span"); wrapper.className = "mx_EditHistoryMessage_deletion"; wrapper.appendChild(child); return wrapper; } function findRefNodes( root: Node, route: number[], isAddition = false, ): { refNode: Node | undefined; refParentNode: Node | undefined; } { let refNode: Node | undefined = root; let refParentNode: Node | undefined; 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 isTextNode(node: Text | HTMLElement): node is Text { return node.nodeName === "#text"; } function diffTreeToDOM(desc: Text | HTMLElement): Node { if (isTextNode(desc)) { return stringAsTextNode(desc.data); } else { const node = document.createElement(desc.nodeName); for (const [key, value] of Object.entries(desc.attributes)) { node.setAttribute(key, value.value); } if (desc.childNodes) { for (const childDesc of desc.childNodes) { node.appendChild(diffTreeToDOM(childDesc as Text | HTMLElement)); } } return node; } } function insertBefore(parent: Node, nextSibling: Node | undefined, child: Node): void { if (nextSibling) { parent.insertBefore(child, nextSibling); } else { parent.appendChild(child); } } function isRouteOfNextSibling(route1: number[], route2: number[]): boolean { // 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: IDiff, remainingDiffs: IDiff[]): void { 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: string): Text { return document.createTextNode(unescape(string)); } function renderDifferenceInDOM(originalRootNode: Node, diff: IDiff, diffMathPatch: DiffMatchPatch): void { const { refNode, refParentNode } = findRefNodes(originalRootNode, diff.route); switch (diff.action) { case "replaceElement": { if (!refNode) { console.warn("Unable to apply replaceElement operation due to missing node"); return; } const container = document.createElement("span"); const delNode = wrapDeletion(diffTreeToDOM(diff.oldValue as HTMLElement)); const insNode = wrapInsertion(diffTreeToDOM(diff.newValue as HTMLElement)); container.appendChild(delNode); container.appendChild(insNode); refNode.parentNode!.replaceChild(container, refNode); break; } case "removeTextElement": { if (!refNode) { console.warn("Unable to apply removeTextElement operation due to missing node"); return; } const delNode = wrapDeletion(stringAsTextNode(diff.value as string)); refNode.parentNode!.replaceChild(delNode, refNode); break; } case "removeElement": { if (!refNode) { console.warn("Unable to apply removeElement operation due to missing node"); return; } const delNode = wrapDeletion(diffTreeToDOM(diff.element as HTMLElement)); refNode.parentNode!.replaceChild(delNode, refNode); break; } case "modifyTextElement": { if (!refNode) { console.warn("Unable to apply modifyTextElement operation due to missing node"); return; } const textDiffs = diffMathPatch.diff_main(diff.oldValue as string, diff.newValue as string); diffMathPatch.diff_cleanupSemantic(textDiffs); const container = document.createElement("span"); for (const [modifier, text] of textDiffs) { let textDiffNode: Node = 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": { if (!refParentNode) { console.warn("Unable to apply addElement operation due to missing node"); return; } const insNode = wrapInsertion(diffTreeToDOM(diff.element as HTMLElement)); insertBefore(refParentNode, refNode, insNode); break; } case "addTextElement": { if (!refParentNode) { console.warn("Unable to apply addTextElement operation due to missing node"); return; } // XXX: sometimes diffDOM says insert a newline when there shouldn't be one // but we must insert the node anyway so that we don't break the route child IDs. // See https://github.com/fiduswriter/diffDOM/issues/100 const insNode = wrapInsertion(stringAsTextNode(diff.value !== "\n" ? (diff.value as string) : "")); 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": { if (!refNode) { console.warn(`Unable to apply ${diff.action} operation due to missing node`); return; } const delNode = wrapDeletion(refNode.cloneNode(true)); const updatedNode = refNode.cloneNode(true) as HTMLElement; if (diff.action === "addAttribute" || diff.action === "modifyAttribute") { updatedNode.setAttribute(diff.name, diff.newValue as string); } 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, ???) logger.warn("MessageDiffUtils::editBodyDiffToHtml: diff action not supported atm", diff); } } /** * Renders a message with the changes made in an edit shown visually. * @param {IContent} originalContent the content for the base message * @param {IContent} editContent the content for the edit message * @return {JSX.Element} a react element similar to what `bodyToHtml` returns */ export function editBodyDiffToHtml(originalContent: IContent, editContent: IContent): JSX.Element { // wrap the body in a div, DiffDOM needs a root element const originalBody = `