Skip to content
This repository has been archived by the owner on Sep 11, 2024. It is now read-only.

Settings toggle to disable Composer Markdown #8358

Merged
merged 7 commits into from
Apr 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions res/css/views/elements/_SettingsFlag.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
20 changes: 19 additions & 1 deletion src/components/views/rooms/BasicMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ interface IProps {
}

interface IState {
useMarkdown: boolean;
showPillAvatar: boolean;
query?: string;
showVisualBell?: boolean;
Expand All @@ -124,6 +125,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
private lastCaret: DocumentOffset;
private lastSelection: ReturnType<typeof cloneSelection>;

private readonly useMarkdownHandle: string;
private readonly emoticonSettingHandle: string;
private readonly shouldShowPillAvatarSettingHandle: string;
private readonly surroundWithHandle: string;
Expand All @@ -133,10 +135,13 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
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();
Expand Down Expand Up @@ -442,7 +447,7 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}
} 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);
}
Expand Down Expand Up @@ -630,6 +635,14 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
this.setState({ completionIndex });
};

private configureUseMarkdown = (): void => {
const useMarkdown = SettingsStore.getValue("MessageComposerInput.useMarkdown");
this.setState({ useMarkdown });
if (!useMarkdown && this.formatBarRef.current) {
this.formatBarRef.current.hide();
}
Comment on lines +641 to +643
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This probably would benefit from being moved to componentDidUpdate and checking that the useMardown state value has changed

};

private configureEmoticonAutoReplace = (): void => {
this.props.model.setTransformCallback(this.transform);
};
Expand All @@ -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("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);
Expand Down Expand Up @@ -694,6 +708,10 @@ export default class BasicMessageEditor extends React.Component<IProps, IState>
}

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);
Expand Down
9 changes: 7 additions & 2 deletions src/components/views/rooms/EditMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -404,7 +407,9 @@ class EditMessageComposer extends React.Component<IEditMessageComposerProps, ISt
} else {
// otherwise, either restore serialized parts from localStorage or parse the body of the event
const restoredParts = this.restoreStoredEditorState(partCreator);
parts = restoredParts || parseEvent(editState.getEvent(), partCreator);
parts = restoredParts || parseEvent(editState.getEvent(), partCreator, {
shouldEscape: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
isRestored = !!restoredParts;
}
this.model = new EditorModel(parts, partCreator);
Expand Down
5 changes: 4 additions & 1 deletion src/components/views/rooms/SendMessageComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,10 @@ export function createMessageContent(
msgtype: isEmote ? "m.emote" : "m.text",
body: body,
};
const formattedBody = htmlSerializeIfNeeded(model, { forceHTML: !!replyToEvent });
const formattedBody = htmlSerializeIfNeeded(model, {
forceHTML: !!replyToEvent,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is still needed.
We had to force HTML rendering when replying because of the reply fallbacks, but this has been scrapped as part of MSC2781.

Let me confirm with someone from the web team

useMarkdown: SettingsStore.getValue("MessageComposerInput.useMarkdown"),
});
if (formattedBody) {
content.format = "org.matrix.custom.html";
content.formatted_body = formattedBody;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export default class PreferencesUserSettingsTab extends React.Component<IProps,

static COMPOSER_SETTINGS = [
'MessageComposerInput.autoReplaceEmoji',
'MessageComposerInput.useMarkdown',
'MessageComposerInput.suggestEmoji',
'sendTypingNotifications',
'MessageComposerInput.ctrlEnterToSend',
Expand Down
79 changes: 42 additions & 37 deletions src/editor/deserialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ function isListChild(n: Node): boolean {
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 parts: Part[] = [];
text.split(ATROOM).forEach((textPart, i, arr) => {
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
Expand All @@ -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

Expand All @@ -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) {
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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":
Expand All @@ -175,52 +175,52 @@ 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("<del>"), ...parseChildren(n, pc), pc.plain("</del>")];
return [pc.plain("<del>"), ...parseChildren(n, pc, opts), pc.plain("</del>")];
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":
return [pc.plain("<sup>"), ...parseChildren(n, pc), pc.plain("</sup>")];
return [pc.plain("<sup>"), ...parseChildren(n, pc, opts), pc.plain("</sup>")];
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":
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);
}
return parts;
}
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;
});
Expand All @@ -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;
Expand All @@ -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());
Expand All @@ -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) {
Expand Down
15 changes: 14 additions & 1 deletion src/editor/serialize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
// copy of raw input to remove unwanted math later
const orig = md;
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/strings/en_EN.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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",
"Match system theme": "Match system theme",
"Use a system font": "Use a system font",
Expand Down
Loading