diff --git a/res/css/views/elements/_SettingsFlag.scss b/res/css/views/elements/_SettingsFlag.scss index 533487d98c..c6f4cf6ec5 100644 --- a/res/css/views/elements/_SettingsFlag.scss +++ b/res/css/views/elements/_SettingsFlag.scss @@ -41,4 +41,10 @@ limitations under the License. font-size: $font-12px; line-height: $font-15px; color: $secondary-content; + + // Support code/pre elements in settings flag descriptions + pre, code { + font-family: $monospace-font-family !important; + background-color: $rte-code-bg-color; + } } diff --git a/src/components/views/rooms/BasicMessageComposer.tsx b/src/components/views/rooms/BasicMessageComposer.tsx index a5dcf03813..e93119643f 100644 --- a/src/components/views/rooms/BasicMessageComposer.tsx +++ b/src/components/views/rooms/BasicMessageComposer.tsx @@ -103,6 +103,7 @@ interface IProps { } interface IState { + useMarkdown: boolean; showPillAvatar: boolean; query?: string; showVisualBell?: boolean; @@ -124,6 +125,7 @@ export default class BasicMessageEditor extends React.Component private lastCaret: DocumentOffset; private lastSelection: ReturnType; + private readonly useMarkdownHandle: string; private readonly emoticonSettingHandle: string; private readonly shouldShowPillAvatarSettingHandle: string; private readonly surroundWithHandle: string; @@ -133,10 +135,13 @@ export default class BasicMessageEditor extends React.Component super(props); this.state = { showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"), showVisualBell: false, }; + this.useMarkdownHandle = SettingsStore.watchSetting('MessageComposerInput.useMarkdown', null, + this.configureUseMarkdown); this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.configureEmoticonAutoReplace); this.configureEmoticonAutoReplace(); @@ -442,7 +447,7 @@ export default class BasicMessageEditor extends React.Component } } else if (!selection.isCollapsed && !isEmpty) { this.hasTextSelected = true; - if (this.formatBarRef.current) { + if (this.formatBarRef.current && this.state.useMarkdown) { const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); this.formatBarRef.current.showAt(selectionRect); } @@ -630,6 +635,14 @@ export default class BasicMessageEditor extends React.Component this.setState({ completionIndex }); }; + private configureUseMarkdown = (): void => { + const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown"); + this.setState({ useMarkdown }); + if (!useMarkdown && this.formatBarRef.current) { + this.formatBarRef.current.hide(); + } + }; + private configureEmoticonAutoReplace = (): void => { this.props.model.setTransformCallback(this.transform); }; @@ -654,6 +667,7 @@ export default class BasicMessageEditor extends React.Component this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true); this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true); + SettingsStore.unwatchSetting(this.useMarkdownHandle); SettingsStore.unwatchSetting(this.emoticonSettingHandle); SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle); SettingsStore.unwatchSetting(this.surroundWithHandle); @@ -694,6 +708,10 @@ export default class BasicMessageEditor extends React.Component } public onFormatAction = (action: Formatting): void => { + if (!this.state.useMarkdown) { + return; + } + const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); this.historyManager.ensureLastChangesPushed(this.props.model); diff --git a/src/components/views/rooms/EditMessageComposer.tsx b/src/components/views/rooms/EditMessageComposer.tsx index ce6d1b844e..de1bdc9c85 100644 --- a/src/components/views/rooms/EditMessageComposer.tsx +++ b/src/components/views/rooms/EditMessageComposer.tsx @@ -95,7 +95,10 @@ function createEditContent( body: `${plainPrefix} * ${body}`, }; - const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply }); + const formattedBody = htmlSerializeIfNeeded(model, { + forceHTML: isReply, + useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"), + }); if (formattedBody) { newContent.format = "org.matrix.custom.html"; newContent.formatted_body = formattedBody; @@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component { if (textPart.length) { - parts.push(...pc.plainWithEmoji(shouldEscape ? escape(textPart) : textPart)); + parts.push(...pc.plainWithEmoji(opts.shouldEscape ? escape(textPart) : textPart)); } // it's safe to never append @room after the last textPart // as split will report an empty string at the end if @@ -70,7 +70,7 @@ function parseAtRoomMentions(text: string, pc: PartCreator, shouldEscape = true) return parts; } -function parseLink(n: Node, pc: PartCreator): Part[] { +function parseLink(n: Node, pc: PartCreator, opts: IParseOptions): Part[] { const { href } = n as HTMLAnchorElement; const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID @@ -81,18 +81,18 @@ function parseLink(n: Node, pc: PartCreator): Part[] { const children = Array.from(n.childNodes); if (href === n.textContent && children.every(c => c.nodeType === Node.TEXT_NODE)) { - return parseAtRoomMentions(n.textContent, pc); + return parseAtRoomMentions(n.textContent, pc, opts); } else { - return [pc.plain("["), ...parseChildren(n, pc), pc.plain(`](${href})`)]; + return [pc.plain("["), ...parseChildren(n, pc, opts), pc.plain(`](${href})`)]; } } -function parseImage(n: Node, pc: PartCreator): Part[] { +function parseImage(n: Node, pc: PartCreator, opts: IParseOptions): Part[] { const { alt, src } = n as HTMLImageElement; return pc.plainWithEmoji(`![${escape(alt)}](${src})`); } -function parseCodeBlock(n: Node, pc: PartCreator): Part[] { +function parseCodeBlock(n: Node, pc: PartCreator, opts: IParseOptions): Part[] { let language = ""; if (n.firstChild?.nodeName === "CODE") { for (const className of (n.firstChild as HTMLElement).classList) { @@ -117,10 +117,10 @@ function parseCodeBlock(n: Node, pc: PartCreator): Part[] { return parts; } -function parseHeader(n: Node, pc: PartCreator): Part[] { +function parseHeader(n: Node, pc: PartCreator, opts: IParseOptions): Part[] { const depth = parseInt(n.nodeName.slice(1), 10); const prefix = pc.plain("#".repeat(depth) + " "); - return [prefix, ...parseChildren(n, pc)]; + return [prefix, ...parseChildren(n, pc, opts)]; } function checkIgnored(n) { @@ -144,10 +144,10 @@ function prefixLines(parts: Part[], prefix: string, pc: PartCreator) { } } -function parseChildren(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): Part[] { +function parseChildren(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] { let prev; return Array.from(n.childNodes).flatMap(c => { - const parsed = parseNode(c, pc, mkListItem); + const parsed = parseNode(c, pc, opts, mkListItem); if (parsed.length && prev && (checkBlockNode(prev) || checkBlockNode(c))) { if (isListChild(c)) { // Use tighter spacing within lists @@ -161,12 +161,12 @@ function parseChildren(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part }); } -function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): Part[] { +function parseNode(n: Node, pc: PartCreator, opts: IParseOptions, mkListItem?: (li: Node) => Part[]): Part[] { if (checkIgnored(n)) return []; switch (n.nodeType) { case Node.TEXT_NODE: - return parseAtRoomMentions(n.nodeValue, pc); + return parseAtRoomMentions(n.nodeValue, pc, opts); case Node.ELEMENT_NODE: switch (n.nodeName) { case "H1": @@ -175,43 +175,43 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): case "H4": case "H5": case "H6": - return parseHeader(n, pc); + return parseHeader(n, pc, opts); case "A": - return parseLink(n, pc); + return parseLink(n, pc, opts); case "IMG": - return parseImage(n, pc); + return parseImage(n, pc, opts); case "BR": return [pc.newline()]; case "HR": return [pc.plain("---")]; case "EM": - return [pc.plain("_"), ...parseChildren(n, pc), pc.plain("_")]; + return [pc.plain("_"), ...parseChildren(n, pc, opts), pc.plain("_")]; case "STRONG": - return [pc.plain("**"), ...parseChildren(n, pc), pc.plain("**")]; + return [pc.plain("**"), ...parseChildren(n, pc, opts), pc.plain("**")]; case "DEL": - return [pc.plain(""), ...parseChildren(n, pc), pc.plain("")]; + return [pc.plain(""), ...parseChildren(n, pc, opts), pc.plain("")]; case "SUB": - return [pc.plain(""), ...parseChildren(n, pc), pc.plain("")]; + return [pc.plain(""), ...parseChildren(n, pc, opts), pc.plain("")]; case "SUP": - return [pc.plain(""), ...parseChildren(n, pc), pc.plain("")]; + return [pc.plain(""), ...parseChildren(n, pc, opts), pc.plain("")]; case "U": - return [pc.plain(""), ...parseChildren(n, pc), pc.plain("")]; + return [pc.plain(""), ...parseChildren(n, pc, opts), pc.plain("")]; case "PRE": - return parseCodeBlock(n, pc); + return parseCodeBlock(n, pc, opts); case "CODE": { // Escape backticks by using multiple backticks for the fence if necessary const fence = "`".repeat(longestBacktickSequence(n.textContent) + 1); return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`); } case "BLOCKQUOTE": { - const parts = parseChildren(n, pc); + const parts = parseChildren(n, pc, opts); prefixLines(parts, "> ", pc); return parts; } case "LI": - return mkListItem?.(n) ?? parseChildren(n, pc); + return mkListItem?.(n) ?? parseChildren(n, pc, opts); case "UL": { - const parts = parseChildren(n, pc, li => [pc.plain("- "), ...parseChildren(li, pc)]); + const parts = parseChildren(n, pc, opts, li => [pc.plain("- "), ...parseChildren(li, pc, opts)]); if (isListChild(n)) { prefixLines(parts, " ", pc); } @@ -219,8 +219,8 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): } case "OL": { let counter = (n as HTMLOListElement).start ?? 1; - const parts = parseChildren(n, pc, li => { - const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc)]; + const parts = parseChildren(n, pc, opts, li => { + const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc, opts)]; counter++; return parts; }); @@ -247,15 +247,20 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]): } } - return parseChildren(n, pc); + return parseChildren(n, pc, opts); } -function parseHtmlMessage(html: string, pc: PartCreator, isQuotedMessage: boolean): Part[] { +interface IParseOptions { + isQuotedMessage?: boolean; + shouldEscape?: boolean; +} + +function parseHtmlMessage(html: string, pc: PartCreator, opts: IParseOptions): Part[] { // no nodes from parsing here should be inserted in the document, // as scripts in event handlers, etc would be executed then. // we're only taking text, so that is fine - const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc); - if (isQuotedMessage) { + const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc, opts); + if (opts.isQuotedMessage) { prefixLines(parts, "> ", pc); } return parts; @@ -264,14 +269,14 @@ function parseHtmlMessage(html: string, pc: PartCreator, isQuotedMessage: boolea export function parsePlainTextMessage( body: string, pc: PartCreator, - opts: { isQuotedMessage?: boolean, shouldEscape?: boolean }, + opts: IParseOptions, ): Part[] { const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n return lines.reduce((parts, line, i) => { if (opts.isQuotedMessage) { parts.push(pc.plain("> ")); } - parts.push(...parseAtRoomMentions(line, pc, opts.shouldEscape)); + parts.push(...parseAtRoomMentions(line, pc, opts)); const isLast = i === lines.length - 1; if (!isLast) { parts.push(pc.newline()); @@ -280,19 +285,19 @@ export function parsePlainTextMessage( }, [] as Part[]); } -export function parseEvent(event: MatrixEvent, pc: PartCreator, { isQuotedMessage = false } = {}) { +export function parseEvent(event: MatrixEvent, pc: PartCreator, opts: IParseOptions = { shouldEscape: true }) { const content = event.getContent(); let parts: Part[]; const isEmote = content.msgtype === "m.emote"; let isRainbow = false; if (content.format === "org.matrix.custom.html") { - parts = parseHtmlMessage(content.formatted_body || "", pc, isQuotedMessage); + parts = parseHtmlMessage(content.formatted_body || "", pc, opts); if (content.body && content.formatted_body && textToHtmlRainbow(content.body) === content.formatted_body) { isRainbow = true; } } else { - parts = parsePlainTextMessage(content.body || "", pc, { isQuotedMessage }); + parts = parsePlainTextMessage(content.body || "", pc, opts); } if (isEmote && isRainbow) { diff --git a/src/editor/serialize.ts b/src/editor/serialize.ts index 8e0d3d66db..7c4d62e9ab 100644 --- a/src/editor/serialize.ts +++ b/src/editor/serialize.ts @@ -17,6 +17,7 @@ limitations under the License. import { AllHtmlEntities } from 'html-entities'; import cheerio from 'cheerio'; +import escapeHtml from "escape-html"; import Markdown from '../Markdown'; import { makeGenericPermalink } from "../utils/permalinks/Permalinks"; @@ -48,7 +49,19 @@ export function mdSerialize(model: EditorModel): string { }, ""); } -export function htmlSerializeIfNeeded(model: EditorModel, { forceHTML = false } = {}): string { +interface ISerializeOpts { + forceHTML?: boolean; + useMarkdown?: boolean; +} + +export function htmlSerializeIfNeeded( + model: EditorModel, + { forceHTML = false, useMarkdown = true }: ISerializeOpts = {}, +): string { + if (!useMarkdown) { + return escapeHtml(textSerialize(model)).replace(/\n/g, '
'); + } + let md = mdSerialize(model); // copy of raw input to remove unwanted math later const orig = md; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 46632c3449..54fc8a8c8f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -932,6 +932,8 @@ "Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", "Surround selected text when typing special characters": "Surround selected text when typing special characters", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji", + "Enable Markdown": "Enable Markdown", + "Start messages with /plain to send without markdown and /md to send with.": "Start messages with /plain to send without markdown and /md to send with.", "Mirror local video feed": "Mirror local video feed", "Match system theme": "Match system theme", "Use a system font": "Use a system font", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fa773080be..d9bd0817a4 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -133,7 +133,7 @@ export interface IBaseSetting { }; // Optional description which will be shown as microCopy under SettingsFlags - description?: string; + description?: string | (() => ReactNode); // The supported levels are required. Preferably, use the preset arrays // at the top of this file to define this rather than a custom array. @@ -611,6 +611,16 @@ export const SETTINGS: {[setting: string]: ISetting} = { displayName: _td('Automatically replace plain text Emoji'), default: false, }, + "MessageComposerInput.useMarkdown": { + supportedLevels: LEVELS_ACCOUNT_SETTINGS, + displayName: _td('Enable Markdown'), + description: () => _t( + "Start messages with /plain to send without markdown and /md to send with.", + {}, + { code: (sub) => { sub } }, + ), + default: true, + }, "VideoView.flipVideoHorizontally": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td('Mirror local video feed'), diff --git a/src/settings/SettingsStore.ts b/src/settings/SettingsStore.ts index 95ea0e6993..19a419afb7 100644 --- a/src/settings/SettingsStore.ts +++ b/src/settings/SettingsStore.ts @@ -16,6 +16,7 @@ limitations under the License. */ import { logger } from "matrix-js-sdk/src/logger"; +import { ReactNode } from "react"; import DeviceSettingsHandler from "./handlers/DeviceSettingsHandler"; import RoomDeviceSettingsHandler from "./handlers/RoomDeviceSettingsHandler"; @@ -257,9 +258,11 @@ export default class SettingsStore { * @param {string} settingName The setting to look up. * @return {String} The description for the setting, or null if not found. */ - public static getDescription(settingName: string) { - if (!SETTINGS[settingName]?.description) return null; - return _t(SETTINGS[settingName].description); + public static getDescription(settingName: string): string | ReactNode { + const description = SETTINGS[settingName]?.description; + if (!description) return null; + if (typeof description !== 'string') return description(); + return _t(description); } /** diff --git a/test/editor/deserialize-test.ts b/test/editor/deserialize-test.ts index 86594f78df..47ab6cb2f2 100644 --- a/test/editor/deserialize-test.ts +++ b/test/editor/deserialize-test.ts @@ -331,4 +331,78 @@ describe('editor/deserialize', function() { 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", + }); + }); + }); }); diff --git a/test/editor/serialize-test.ts b/test/editor/serialize-test.ts index 40f95e0377..d948285901 100644 --- a/test/editor/serialize-test.ts +++ b/test/editor/serialize-test.ts @@ -19,58 +19,80 @@ import { htmlSerializeIfNeeded } from "../../src/editor/serialize"; import { createPartCreator } from "./mock"; describe('editor/serialize', function() { - it('user pill turns message into html', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Alice"); + describe('with markdown', function() { + it('user pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Alice"); + }); + it('room pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("#room:hs.tld"); + }); + it('@room pill turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.atRoomPill("@room")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBeFalsy(); + }); + it('any markdown turns message into html', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("*hello* world")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("hello world"); + }); + it('displaynames ending in a backslash work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname\\"); + }); + it('displaynames containing an opening square bracket work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname[["); + }); + it('displaynames containing a closing square bracket work', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe("Displayname]"); + }); + it('escaped markdown should not retain backslashes', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain('\\*hello\\* world')], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe('*hello* world'); + }); + it('escaped markdown should convert HTML entities', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc); + const html = htmlSerializeIfNeeded(model, {}); + expect(html).toBe('*hello* world < hey world!'); + }); }); - it('room pill turns message into html', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.roomPill("#room:hs.tld")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("#room:hs.tld"); - }); - it('@room pill turns message into html', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.atRoomPill("@room")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBeFalsy(); - }); - it('any markdown turns message into html', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.plain("*hello* world")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("hello world"); - }); - it('displaynames ending in a backslash work', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.userPill("Displayname\\", "@user:server")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Displayname\\"); - }); - it('displaynames containing an opening square bracket work', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.userPill("Displayname[[", "@user:server")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Displayname[["); - }); - it('displaynames containing a closing square bracket work', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.userPill("Displayname]", "@user:server")], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe("Displayname]"); - }); - it('escaped markdown should not retain backslashes', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.plain('\\*hello\\* world')], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe('*hello* world'); - }); - it('escaped markdown should convert HTML entities', function() { - const pc = createPartCreator(); - const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc); - const html = htmlSerializeIfNeeded(model, {}); - expect(html).toBe('*hello* world < hey world!'); + describe('with plaintext', function() { + it('markdown remains plaintext', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain("*hello* world")], pc); + const html = htmlSerializeIfNeeded(model, { useMarkdown: false }); + expect(html).toBe("*hello* world"); + }); + it('markdown should retain backslashes', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain('\\*hello\\* world')], pc); + const html = htmlSerializeIfNeeded(model, { useMarkdown: false }); + expect(html).toBe('\\*hello\\* world'); + }); + it('markdown should convert HTML entities', function() { + const pc = createPartCreator(); + const model = new EditorModel([pc.plain('\\*hello\\* world < hey world!')], pc); + const html = htmlSerializeIfNeeded(model, { useMarkdown: false }); + expect(html).toBe('\\*hello\\* world < hey world!'); + }); }); });