/* Copyright 2024 New Vector Ltd. Copyright 2019 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 { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { parseEvent } from "../../../src/editor/deserialize"; import { Part } from "../../../src/editor/parts"; import { createPartCreator } from "./mock"; const FOUR_SPACES = " ".repeat(4); function htmlMessage(formattedBody: string, msgtype = "m.text") { return { getContent() { return { msgtype, format: "org.matrix.custom.html", formatted_body: formattedBody, }; }, } as unknown as MatrixEvent; } function textMessage(body: string, msgtype = "m.text") { return { getContent() { return { msgtype, body, }; }, } as unknown as MatrixEvent; } function textMessageReply(body: string, msgtype = "m.text") { return { ...textMessage(body, msgtype), replyEventId: "!foo:bar", } as unknown as MatrixEvent; } function mergeAdjacentParts(parts: Part[]) { let prevPart: Part | undefined; for (let i = 0; i < parts.length; ++i) { let part: Part | undefined = parts[i]; const isEmpty = !part.text.length; const isMerged = !isEmpty && prevPart && prevPart.merge?.(part); if (isEmpty || isMerged) { // remove empty or merged part part = prevPart; parts.splice(i, 1); //repeat this index, as it's removed now --i; } prevPart = part; } } function normalize(parts: Part[]) { // merge adjacent parts as this will happen // in the model anyway, and whether 1 or multiple // plain parts are returned is an implementation detail mergeAdjacentParts(parts); // convert to data objects for easier asserting return parts.map((p) => p.serialize()); } describe("editor/deserialize", function () { describe("text messages", function () { it("test with newlines", function () { const parts = normalize(parseEvent(textMessage("hello\nworld"), createPartCreator())); expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "world" }); expect(parts.length).toBe(3); }); it("@room pill", function () { const parts = normalize(parseEvent(textMessage("text message for @room"), createPartCreator())); expect(parts.length).toBe(2); expect(parts[0]).toStrictEqual({ type: "plain", text: "text message for " }); expect(parts[1]).toStrictEqual({ type: "at-room-pill", text: "@room" }); }); it("emote", function () { const text = "says DON'T SHOUT!"; const parts = normalize(parseEvent(textMessage(text, "m.emote"), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "/me says DON'T SHOUT!" }); }); it("spoiler", function () { const parts = normalize(parseEvent(textMessage("/spoiler broiler"), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "/spoiler broiler" }); }); }); describe("html messages", function () { it("inline styling", function () { const html = "bold and emphasized text"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "**bold** and _emphasized_ text" }); }); it("hyperlink", function () { const html = 'click this!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "click [this](http://example.com/)!" }); }); it("multiple lines with paragraphs", function () { const html = "

hello

world

"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(4); expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[3]).toStrictEqual({ type: "plain", text: "world" }); }); it("multiple lines with line breaks", function () { const html = "hello
world"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "world" }); }); it("multiple lines mixing paragraphs and line breaks", function () { const html = "

hello
warm

world

"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(6); expect(parts[0]).toStrictEqual({ type: "plain", text: "hello" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "warm" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[5]).toStrictEqual({ type: "plain", text: "world" }); }); it("quote", function () { const html = "

wise
words

indeed

"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(6); expect(parts[0]).toStrictEqual({ type: "plain", text: "> _wise_" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "> **words**" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[5]).toStrictEqual({ type: "plain", text: "indeed" }); }); it("user pill", function () { const html = 'Hi Alice!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice", resourceId: "@alice:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it("user pill with displayname containing backslash", function () { const html = 'Hi Alice\\!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice\\", resourceId: "@alice:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it("user pill with displayname containing opening square bracket", function () { const html = 'Hi Alice[[!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice[[", resourceId: "@alice:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it("user pill with displayname containing closing square bracket", function () { const html = 'Hi Alice]!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice]", resourceId: "@alice:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it("user pill with displayname containing linebreak", function () { const html = 'Hi Alice
123
!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Hi " }); expect(parts[1]).toStrictEqual({ type: "user-pill", text: "Alice123", resourceId: "@alice:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "!" }); }); it("room pill", function () { const html = 'Try #room:hs.tld?'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(3); expect(parts[0]).toStrictEqual({ type: "plain", text: "Try " }); expect(parts[1]).toStrictEqual({ type: "room-pill", text: "#room:hs.tld", resourceId: "#room:hs.tld" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "?" }); }); it("@room pill", function () { const html = "formatted message for @room"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(2); expect(parts[0]).toStrictEqual({ type: "plain", text: "_formatted_ message for " }); expect(parts[1]).toStrictEqual({ type: "at-room-pill", text: "@room" }); }); it("inline code", function () { const html = "there is no place like 127.0.0.1!"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "there is no place like `127.0.0.1`!" }); }); it("code block with no trailing text", function () { const html = "
0xDEADBEEF\n
\n"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "```" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "0xDEADBEEF" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: "```" }); }); // failing likely because of https://github.com/vector-im/element-web/issues/10316 it.skip("code block with no trailing text and no newlines", function () { const html = "
0xDEADBEEF
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "```" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "0xDEADBEEF" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: "```" }); }); it("unordered lists", function () { const html = ""; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "- Oak" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "- Spruce" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: "- Birch" }); }); it("ordered lists", function () { const html = "
  1. Start
  2. Continue
  3. Finish
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Start" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: "2. Continue" }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: "3. Finish" }); }); it("nested unordered lists", () => { const html = ""; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "- Oak" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES}- Spruce` }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}- Birch` }); }); it("nested ordered lists", () => { const html = "
  1. Oak
    1. Spruce
      1. Birch
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Oak" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES}1. Spruce` }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}1. Birch` }); }); it("nested lists", () => { const html = "
  1. Oak\n
    1. Spruce\n
      1. Birch
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(5); expect(parts[0]).toStrictEqual({ type: "plain", text: "1. Oak\n" }); expect(parts[1]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[2]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES}1. Spruce\n` }); expect(parts[3]).toStrictEqual({ type: "newline", text: "\n" }); expect(parts[4]).toStrictEqual({ type: "plain", text: `${FOUR_SPACES.repeat(2)}1. Birch` }); }); it("mx-reply is stripped", function () { const html = "foobar"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "bar" }); }); it("emote", function () { const html = "says DON'T SHOUT!"; const parts = normalize(parseEvent(htmlMessage(html, "m.emote"), createPartCreator())); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "/me says _DON'T SHOUT_!" }); }); it("spoiler", function () { const parts = normalize( parseEvent(htmlMessage("broiler"), createPartCreator()), ); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "/spoiler broiler" }); }); it("preserves nested quotes", () => { const html = "
foo
bar
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("surrounds lists with newlines", () => { const html = "foobaz"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("preserves nested formatting", () => { const html = "abcde"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes backticks in code blocks", () => { const html = "

this → ` is a backtick

" + "
and here are 3 of them:\n```
"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes backticks outside of code blocks", () => { const html = "some `backticks`"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes backslashes", () => { const html = "C:\\My Documents"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes asterisks", () => { const html = "*hello*"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes underscores", () => { const html = "__emphasis__"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes square brackets", () => { const html = "[not an actual link](https://example.org)"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); it("escapes angle brackets", () => { const html = "> \\no formatting here\\"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator())); expect(parts).toMatchSnapshot(); }); }); describe("plaintext messages", function () { it("turns html tags back into markdown", function () { const html = 'bold and emphasized text this!'; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "**bold** and _emphasized_ text [this](http://example.com/)!", }); }); it("keeps backticks unescaped", () => { const html = "this → ` is a backtick and here are 3 of them:\n```"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "this → ` is a backtick and here are 3 of them:\n```", }); }); it("keeps backticks outside of code blocks", () => { const html = "some `backticks`"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "some `backticks`", }); }); it("keeps backslashes", () => { const html = "C:\\My Documents"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "C:\\My Documents", }); }); it("keeps asterisks", () => { const html = "*hello*"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "*hello*", }); }); it("keeps underscores", () => { const html = "__emphasis__"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "__emphasis__", }); }); it("keeps square brackets", () => { const html = "[not an actual link](https://example.org)"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "[not an actual link](https://example.org)", }); }); it("escapes angle brackets", () => { const html = "> <del>no formatting here</del>"; const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "> no formatting here", }); }); it("strips plaintext replies", () => { const body = "> Sender: foo\n\nMessage"; const parts = normalize(parseEvent(textMessageReply(body), createPartCreator(), { shouldEscape: false })); expect(parts.length).toBe(1); expect(parts[0]).toStrictEqual({ type: "plain", text: "Message", }); }); }); });