/*
Copyright 2024 New Vector Ltd.
Copyright 2019, 2020 The Matrix.org Foundation C.I.C.
Copyright 2019 New Vector Ltd
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { encode } from "html-entities";
import escapeHtml from "escape-html";
import Markdown from "../Markdown";
import { makeGenericPermalink } from "../utils/permalinks/Permalinks";
import EditorModel from "./model";
import SettingsStore from "../settings/SettingsStore";
import SdkConfig from "../SdkConfig";
import { Type } from "./parts";
export function mdSerialize(model: EditorModel): string {
return model.parts.reduce((html, part) => {
switch (part.type) {
case Type.Newline:
return html + "\n";
case Type.Plain:
case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return html + part.text;
case Type.RoomPill: {
const url = makeGenericPermalink(part.resourceId, true);
// Escape square brackets and backslashes
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
const title = part.resourceId.replace(/[[\\\]]/g, (c) => "\\" + c);
return html + `[${title}](${url})`;
}
case Type.UserPill: {
const url = makeGenericPermalink(part.resourceId, true);
// Escape square brackets and backslashes; convert newlines to HTML
const title = part.text.replace(/[[\\\]]/g, (c) => "\\" + c).replace(/\n/g, "
");
return html + `[${title}](${url})`;
}
}
}, "");
}
interface ISerializeOpts {
forceHTML?: boolean;
useMarkdown?: boolean;
}
export function htmlSerializeIfNeeded(
model: EditorModel,
{ forceHTML = false, useMarkdown = true }: ISerializeOpts = {},
): string | undefined {
if (!useMarkdown) {
return escapeHtml(textSerialize(model)).replace(/\n/g, "
");
}
const md = mdSerialize(model);
return htmlSerializeFromMdIfNeeded(md, { forceHTML });
}
export function htmlSerializeFromMdIfNeeded(md: string, { forceHTML = false } = {}): string | undefined {
// copy of raw input to remove unwanted math later
const orig = md;
if (SettingsStore.getValue("feature_latex_maths")) {
const patternNames = ["tex", "latex"] as const;
const patternTypes = ["display", "inline"] as const;
const patternDefaults = {
tex: {
// detect math with tex delimiters, inline: $...$, display $$...$$
// preferably use negative lookbehinds, not supported in all major browsers:
// const displayPattern = "^(?\n\n\n\n`;
case "inline":
return `${p1}`;
}
});
});
});
// make sure div tags always start on a new line, otherwise it will confuse the markdown parser
md = md.replace(/(.)
${tex}
`;
}
});
}
return phtml.body.innerHTML;
}
// ensure removal of escape backslashes in non-Markdown messages
if (md.indexOf("\\") > -1) {
return parser.toPlaintext();
}
}
export function textSerialize(model: EditorModel): string {
return model.parts.reduce((text, part) => {
switch (part.type) {
case Type.Newline:
return text + "\n";
case Type.Plain:
case Type.Emoji:
case Type.Command:
case Type.PillCandidate:
case Type.AtRoomPill:
return text + part.text;
case Type.RoomPill:
// Here we use the resourceId for compatibility with non-rich text clients
// See https://github.com/vector-im/element-web/issues/16660
return text + `${part.resourceId}`;
case Type.UserPill:
return text + `${part.text}`;
}
}, "");
}
export function containsEmote(model: EditorModel): boolean {
const hasCommand = startsWith(model, "/me ", false);
const hasArgument = model.parts[0]?.text?.length > 4 || model.parts.length > 1;
return hasCommand && hasArgument;
}
export function startsWith(model: EditorModel, prefix: string, caseSensitive = true): boolean {
const firstPart = model.parts[0];
// part type will be "plain" while editing,
// and "command" while composing a message.
let text = firstPart?.text || "";
if (!caseSensitive) {
prefix = prefix.toLowerCase();
text = text.toLowerCase();
}
return firstPart && (firstPart.type === Type.Plain || firstPart.type === Type.Command) && text.startsWith(prefix);
}
export function stripEmoteCommand(model: EditorModel): EditorModel {
// trim "/me "
return stripPrefix(model, "/me ");
}
export function stripPrefix(model: EditorModel, prefix: string): EditorModel {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, prefix.length);
return model;
}
export function unescapeMessage(model: EditorModel): EditorModel {
const { parts } = model;
if (parts.length) {
const firstPart = parts[0];
// only unescape \/ to / at start of editor
if (firstPart.type === Type.Plain && firstPart.text.startsWith("\\/")) {
model = model.clone();
model.removeText({ index: 0, offset: 0 }, 1);
}
}
return model;
}