Settings toggle to disable Composer Markdown (#8358)
This commit is contained in:
parent
f4d935d88d
commit
bca9caa98e
12 changed files with 260 additions and 98 deletions
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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'),
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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 = "> <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: "> <del>no formatting here</del>",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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 < hey world!');
|
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!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
Loading…
Reference in a new issue