Settings toggle to disable Composer Markdown (#8358)

This commit is contained in:
Janne Mareike Koschinski 2022-04-19 15:53:59 +02:00 committed by GitHub
parent f4d935d88d
commit bca9caa98e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 260 additions and 98 deletions

View file

@ -41,4 +41,10 @@ limitations under the License.
font-size: $font-12px; font-size: $font-12px;
line-height: $font-15px; line-height: $font-15px;
color: $secondary-content; 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;
}
} }

View file

@ -103,6 +103,7 @@ interface IProps {
} }
interface IState { interface IState {
useMarkdown: boolean;
showPillAvatar: boolean; showPillAvatar: boolean;
query?: string; query?: string;
showVisualBell?: boolean; showVisualBell?: boolean;
@ -124,6 +125,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private lastCaret: DocumentOffset; private lastCaret: DocumentOffset;
private lastSelection: ReturnType<typeof cloneSelection>; private lastSelection: ReturnType<typeof cloneSelection>;
private readonly useMarkdownHandle: string;
private readonly emoticonSettingHandle: string; private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string; private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string; private readonly surroundWithHandle: string;
@ -133,10 +135,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
super(props); super(props);
this.state = { this.state = {
showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"), showPillAvatar: SettingsStore.getValue("Pill.shouldShowPillAvatar"),
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"), surroundWith: SettingsStore.getValue("MessageComposerInput.surroundWith"),
showVisualBell: false, showVisualBell: false,
}; };
this.useMarkdownHandle = SettingsStore.watchSetting('MessageComposerInput.useMarkdown', null,
this.configureUseMarkdown);
this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null, this.emoticonSettingHandle = SettingsStore.watchSetting('MessageComposerInput.autoReplaceEmoji', null,
this.configureEmoticonAutoReplace); this.configureEmoticonAutoReplace);
this.configureEmoticonAutoReplace(); this.configureEmoticonAutoReplace();
@ -442,7 +447,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
} else if (!selection.isCollapsed && !isEmpty) { } else if (!selection.isCollapsed && !isEmpty) {
this.hasTextSelected = true; this.hasTextSelected = true;
if (this.formatBarRef.current) { if (this.formatBarRef.current && this.state.useMarkdown) {
const selectionRect = selection.getRangeAt(0).getBoundingClientRect(); const selectionRect = selection.getRangeAt(0).getBoundingClientRect();
this.formatBarRef.current.showAt(selectionRect); this.formatBarRef.current.showAt(selectionRect);
} }
@ -630,6 +635,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ completionIndex }); 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 => { private configureEmoticonAutoReplace = (): void => {
this.props.model.setTransformCallback(this.transform); this.props.model.setTransformCallback(this.transform);
}; };
@ -654,6 +667,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.editorRef.current.removeEventListener("input", this.onInput, true); this.editorRef.current.removeEventListener("input", this.onInput, true);
this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true); this.editorRef.current.removeEventListener("compositionstart", this.onCompositionStart, true);
this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true); this.editorRef.current.removeEventListener("compositionend", this.onCompositionEnd, true);
SettingsStore.unwatchSetting(this.useMarkdownHandle);
SettingsStore.unwatchSetting(this.emoticonSettingHandle); SettingsStore.unwatchSetting(this.emoticonSettingHandle);
SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle); SettingsStore.unwatchSetting(this.shouldShowPillAvatarSettingHandle);
SettingsStore.unwatchSetting(this.surroundWithHandle); SettingsStore.unwatchSetting(this.surroundWithHandle);
@ -694,6 +708,10 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
} }
public onFormatAction = (action: Formatting): void => { public onFormatAction = (action: Formatting): void => {
if (!this.state.useMarkdown) {
return;
}
const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection()); const range: Range = getRangeForSelection(this.editorRef.current, this.props.model, document.getSelection());
this.historyManager.ensureLastChangesPushed(this.props.model); this.historyManager.ensureLastChangesPushed(this.props.model);

View file

@ -95,7 +95,10 @@ function createEditContent(
body: `${plainPrefix} * ${body}`, body: `${plainPrefix} * ${body}`,
}; };
const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: isReply }); const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: isReply,
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) { if (formattedBody) {
newContent.format = "org.matrix.custom.html"; newContent.format = "org.matrix.custom.html";
newContent.formatted_body = formattedBody; newContent.formatted_body = formattedBody;
@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
} else { } else {
// otherwise, either restore serialized parts from localStorage or parse the body of the event // otherwise, either restore serialized parts from localStorage or parse the body of the event
const restoredParts = this.restoreStoredEditorState(partCreator); const restoredParts = this.restoreStoredEditorState(partCreator);
parts = restoredParts || parseEvent(editState.getEvent(), partCreator); parts = restoredParts || parseEvent(editState.getEvent(), partCreator, {
shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
isRestored = !!restoredParts; isRestored = !!restoredParts;
} }
this.model = new EditorModel(parts, partCreator); this.model = new EditorModel(parts, partCreator);

View file

@ -91,7 +91,10 @@ export function createMessageContent(
msgtype: isEmote ? "m.emote" : "m.text", msgtype: isEmote ? "m.emote" : "m.text",
body: body, body: body,
}; };
const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: !!replyToEvent }); const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: !!replyToEvent,
useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) { if (formattedBody) {
content.format = "org.matrix.custom.html"; content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody; content.formatted_body = formattedBody;

View file

@ -63,6 +63,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,
static COMPOSER_SETTINGS = [ static COMPOSER_SETTINGS = [
'MessageComposerInput.autoReplaceEmoji', 'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.useMarkdown',
'MessageComposerInput.suggestEmoji', 'MessageComposerInput.suggestEmoji',
'sendTypingNotifications', 'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend', 'MessageComposerInput.ctrlEnterToSend',

View file

@ -52,12 +52,12 @@ function isListChild(n: Node): boolean {
return LIST_TYPES.includes(n.parentNode?.nodeName); return LIST_TYPES.includes(n.parentNode?.nodeName);
} }
function parseAtRoomMentions(text: string, pc: PartCreator, shouldEscape = true): Part[] { function parseAtRoomMentions(text: string, pc: PartCreator, opts: IParseOptions): Part[] {
const ATROOM = "@room"; const ATROOM = "@room";
const parts: Part[] = []; const parts: Part[] = [];
text.split(ATROOM).forEach((textPart, i, arr) => { text.split(ATROOM).forEach((textPart, i, arr) => {
if (textPart.length) { 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 // it's safe to never append @room after the last textPart
// as split will report an empty string at the end if // 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; return parts;
} }
function parseLink(n: Node, pc: PartCreator): Part[] { function parseLink(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
const { href } = n as HTMLAnchorElement; const { href } = n as HTMLAnchorElement;
const resourceId = getPrimaryPermalinkEntity(href); // The room/user ID 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); const children = Array.from(n.childNodes);
if (href === n.textContent && children.every(c => c.nodeType === Node.TEXT_NODE)) { if (href === n.textContent && children.every(c => c.nodeType === Node.TEXT_NODE)) {
return parseAtRoomMentions(n.textContent, pc); return parseAtRoomMentions(n.textContent, pc, opts);
} else { } 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; const { alt, src } = n as HTMLImageElement;
return pc.plainWithEmoji(`![${escape(alt)}](${src})`); return pc.plainWithEmoji(`![${escape(alt)}](${src})`);
} }
function parseCodeBlock(n: Node, pc: PartCreator): Part[] { function parseCodeBlock(n: Node, pc: PartCreator, opts: IParseOptions): Part[] {
let language = ""; let language = "";
if (n.firstChild?.nodeName === "CODE") { if (n.firstChild?.nodeName === "CODE") {
for (const className of (n.firstChild as HTMLElement).classList) { for (const className of (n.firstChild as HTMLElement).classList) {
@ -117,10 +117,10 @@ function parseCodeBlock(n: Node, pc: PartCreator): Part[] {
return parts; 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 depth = parseInt(n.nodeName.slice(1), 10);
const prefix = pc.plain("#".repeat(depth) + " "); const prefix = pc.plain("#".repeat(depth) + " ");
return [prefix, ...parseChildren(n, pc)]; return [prefix, ...parseChildren(n, pc, opts)];
} }
function checkIgnored(n) { 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; let prev;
return Array.from(n.childNodes).flatMap(c => { 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 (parsed.length && prev && (checkBlockNode(prev) || checkBlockNode(c))) {
if (isListChild(c)) { if (isListChild(c)) {
// Use tighter spacing within lists // 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 []; if (checkIgnored(n)) return [];
switch (n.nodeType) { switch (n.nodeType) {
case Node.TEXT_NODE: case Node.TEXT_NODE:
return parseAtRoomMentions(n.nodeValue, pc); return parseAtRoomMentions(n.nodeValue, pc, opts);
case Node.ELEMENT_NODE: case Node.ELEMENT_NODE:
switch (n.nodeName) { switch (n.nodeName) {
case "H1": case "H1":
@ -175,43 +175,43 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]):
case "H4": case "H4":
case "H5": case "H5":
case "H6": case "H6":
return parseHeader(n, pc); return parseHeader(n, pc, opts);
case "A": case "A":
return parseLink(n, pc); return parseLink(n, pc, opts);
case "IMG": case "IMG":
return parseImage(n, pc); return parseImage(n, pc, opts);
case "BR": case "BR":
return [pc.newline()]; return [pc.newline()];
case "HR": case "HR":
return [pc.plain("---")]; return [pc.plain("---")];
case "EM": case "EM":
return [pc.plain("_"), ...parseChildren(n, pc), pc.plain("_")]; return [pc.plain("_"), ...parseChildren(n, pc, opts), pc.plain("_")];
case "STRONG": case "STRONG":
return [pc.plain("**"), ...parseChildren(n, pc), pc.plain("**")]; return [pc.plain("**"), ...parseChildren(n, pc, opts), pc.plain("**")];
case "DEL": case "DEL":
return [pc.plain("<del>"), ...parseChildren(n, pc), pc.plain("</del>")]; return [pc.plain("<del>"), ...parseChildren(n, pc, opts), pc.plain("</del>")];
case "SUB": case "SUB":
return [pc.plain("<sub>"), ...parseChildren(n, pc), pc.plain("</sub>")]; return [pc.plain("<sub>"), ...parseChildren(n, pc, opts), pc.plain("</sub>")];
case "SUP": case "SUP":
return [pc.plain("<sup>"), ...parseChildren(n, pc), pc.plain("</sup>")]; return [pc.plain("<sup>"), ...parseChildren(n, pc, opts), pc.plain("</sup>")];
case "U": case "U":
return [pc.plain("<u>"), ...parseChildren(n, pc), pc.plain("</u>")]; return [pc.plain("<u>"), ...parseChildren(n, pc, opts), pc.plain("</u>")];
case "PRE": case "PRE":
return parseCodeBlock(n, pc); return parseCodeBlock(n, pc, opts);
case "CODE": { case "CODE": {
// Escape backticks by using multiple backticks for the fence if necessary // Escape backticks by using multiple backticks for the fence if necessary
const fence = "`".repeat(longestBacktickSequence(n.textContent) + 1); const fence = "`".repeat(longestBacktickSequence(n.textContent) + 1);
return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`); return pc.plainWithEmoji(`${fence}${n.textContent}${fence}`);
} }
case "BLOCKQUOTE": { case "BLOCKQUOTE": {
const parts = parseChildren(n, pc); const parts = parseChildren(n, pc, opts);
prefixLines(parts, "> ", pc); prefixLines(parts, "> ", pc);
return parts; return parts;
} }
case "LI": case "LI":
return mkListItem?.(n) ?? parseChildren(n, pc); return mkListItem?.(n) ?? parseChildren(n, pc, opts);
case "UL": { 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)) { if (isListChild(n)) {
prefixLines(parts, " ", pc); prefixLines(parts, " ", pc);
} }
@ -219,8 +219,8 @@ function parseNode(n: Node, pc: PartCreator, mkListItem?: (li: Node) => Part[]):
} }
case "OL": { case "OL": {
let counter = (n as HTMLOListElement).start ?? 1; let counter = (n as HTMLOListElement).start ?? 1;
const parts = parseChildren(n, pc, li => { const parts = parseChildren(n, pc, opts, li => {
const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc)]; const parts = [pc.plain(`${counter}. `), ...parseChildren(li, pc, opts)];
counter++; counter++;
return parts; 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, // no nodes from parsing here should be inserted in the document,
// as scripts in event handlers, etc would be executed then. // as scripts in event handlers, etc would be executed then.
// we're only taking text, so that is fine // we're only taking text, so that is fine
const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc); const parts = parseNode(new DOMParser().parseFromString(html, "text/html").body, pc, opts);
if (isQuotedMessage) { if (opts.isQuotedMessage) {
prefixLines(parts, "> ", pc); prefixLines(parts, "> ", pc);
} }
return parts; return parts;
@ -264,14 +269,14 @@ function parseHtmlMessage(html: string, pc: PartCreator, isQuotedMessage: boolea
export function parsePlainTextMessage( export function parsePlainTextMessage(
body: string, body: string,
pc: PartCreator, pc: PartCreator,
opts: { isQuotedMessage?: boolean, shouldEscape?: boolean }, opts: IParseOptions,
): Part[] { ): Part[] {
const lines = body.split(/\r\n|\r|\n/g); // split on any new-line combination not just \n, collapses \r\n 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) => { return lines.reduce((parts, line, i) => {
if (opts.isQuotedMessage) { if (opts.isQuotedMessage) {
parts.push(pc.plain("> ")); parts.push(pc.plain("> "));
} }
parts.push(...parseAtRoomMentions(line, pc, opts.shouldEscape)); parts.push(...parseAtRoomMentions(line, pc, opts));
const isLast = i === lines.length - 1; const isLast = i === lines.length - 1;
if (!isLast) { if (!isLast) {
parts.push(pc.newline()); parts.push(pc.newline());
@ -280,19 +285,19 @@ export function parsePlainTextMessage(
}, [] as Part[]); }, [] 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(); const content = event.getContent();
let parts: Part[]; let parts: Part[];
const isEmote = content.msgtype === "m.emote"; const isEmote = content.msgtype === "m.emote";
let isRainbow = false; let isRainbow = false;
if (content.format === "org.matrix.custom.html") { 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) { if (content.body && content.formatted_body && textToHtmlRainbow(content.body) === content.formatted_body) {
isRainbow = true; isRainbow = true;
} }
} else { } else {
parts = parsePlainTextMessage(content.body || "", pc, { isQuotedMessage }); parts = parsePlainTextMessage(content.body || "", pc, opts);
} }
if (isEmote && isRainbow) { if (isEmote && isRainbow) {

View file

@ -17,6 +17,7 @@ limitations under the License.
import { AllHtmlEntities } from 'html-entities'; import { AllHtmlEntities } from 'html-entities';
import cheerio from 'cheerio'; import cheerio from 'cheerio';
import escapeHtml from "escape-html";
import Markdown from '../Markdown'; import Markdown from '../Markdown';
import { makeGenericPermalink } from "../utils/permalinks/Permalinks"; 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, '<br/>');
}
let md = mdSerialize(model); let md = mdSerialize(model);
// copy of raw input to remove unwanted math later // copy of raw input to remove unwanted math later
const orig = md; const orig = md;

View file

@ -932,6 +932,8 @@
"Use Ctrl + Enter to send a message": "Use Ctrl + Enter to send a message", "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", "Surround selected text when typing special characters": "Surround selected text when typing special characters",
"Automatically replace plain text Emoji": "Automatically replace plain text Emoji", "Automatically replace plain text Emoji": "Automatically replace plain text Emoji",
"Enable Markdown": "Enable Markdown",
"Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.": "Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.",
"Mirror local video feed": "Mirror local video feed", "Mirror local video feed": "Mirror local video feed",
"Match system theme": "Match system theme", "Match system theme": "Match system theme",
"Use a system font": "Use a system font", "Use a system font": "Use a system font",

View file

@ -133,7 +133,7 @@ export interface IBaseSetting<T extends SettingValueType = SettingValueType> {
}; };
// Optional description which will be shown as microCopy under SettingsFlags // 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 // The supported levels are required. Preferably, use the preset arrays
// at the top of this file to define this rather than a custom array. // 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'), displayName: _td('Automatically replace plain text Emoji'),
default: false, default: false,
}, },
"MessageComposerInput.useMarkdown": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Enable Markdown'),
description: () => _t(
"Start messages with <code>/plain</code> to send without markdown and <code>/md</code> to send with.",
{},
{ code: (sub) => <code>{ sub }</code> },
),
default: true,
},
"VideoView.flipVideoHorizontally": { "VideoView.flipVideoHorizontally": {
supportedLevels: LEVELS_ACCOUNT_SETTINGS, supportedLevels: LEVELS_ACCOUNT_SETTINGS,
displayName: _td('Mirror local video feed'), displayName: _td('Mirror local video feed'),

View file

@ -16,6 +16,7 @@ limitations under the License.
*/ */
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { ReactNode } from "react";
import DeviceSettingsHandler from "./handlers/DeviceSettingsHandler"; import DeviceSettingsHandler from "./handlers/DeviceSettingsHandler";
import RoomDeviceSettingsHandler from "./handlers/RoomDeviceSettingsHandler"; import RoomDeviceSettingsHandler from "./handlers/RoomDeviceSettingsHandler";
@ -257,9 +258,11 @@ export default class SettingsStore {
* @param {string} settingName The setting to look up. * @param {string} settingName The setting to look up.
* @return {String} The description for the setting, or null if not found. * @return {String} The description for the setting, or null if not found.
*/ */
public static getDescription(settingName: string) { public static getDescription(settingName: string): string | ReactNode {
if (!SETTINGS[settingName]?.description) return null; const description = SETTINGS[settingName]?.description;
return _t(SETTINGS[settingName].description); if (!description) return null;
if (typeof description !== 'string') return description();
return _t(description);
} }
/** /**

View file

@ -331,4 +331,78 @@ describe('editor/deserialize', function() {
expect(parts).toMatchSnapshot(); expect(parts).toMatchSnapshot();
}); });
}); });
describe('plaintext messages', function() {
it('turns html tags back into markdown', function() {
const html = "<strong>bold</strong> and <em>emphasized</em> text <a href=\"http://example.com/\">this</a>!";
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 = "> &lt;del&gt;no formatting here&lt;/del&gt;";
const parts = normalize(parseEvent(htmlMessage(html), createPartCreator(), { shouldEscape: false }));
expect(parts.length).toBe(1);
expect(parts[0]).toStrictEqual({
type: "plain",
text: "> <del>no formatting here</del>",
});
});
});
}); });

View file

@ -19,6 +19,7 @@ import { htmlSerializeIfNeeded } from "../../src/editor/serialize";
import { createPartCreator } from "./mock"; import { createPartCreator } from "./mock";
describe('editor/serialize', function() { describe('editor/serialize', function() {
describe('with markdown', function() {
it('user pill turns message into html', function() { it('user pill turns message into html', function() {
const pc = createPartCreator(); const pc = createPartCreator();
const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc); const model = new EditorModel([pc.userPill("Alice", "@alice:hs.tld")], pc);
@ -74,3 +75,24 @@ describe('editor/serialize', function() {
expect(html).toBe('*hello* world &lt; hey world!'); expect(html).toBe('*hello* world &lt; 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 &lt; hey world!');
});
});
});