diff --git a/.changeset/fix-emojis-edits.md b/.changeset/fix-emojis-edits.md new file mode 100644 index 000000000..9d42e0fed --- /dev/null +++ b/.changeset/fix-emojis-edits.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix editing messages with custom emojis being converted into html tags. diff --git a/src/app/components/editor/input.ts b/src/app/components/editor/input.ts index 7222cfcae..471a0e5f5 100644 --- a/src/app/components/editor/input.ts +++ b/src/app/components/editor/input.ts @@ -1,19 +1,67 @@ import type { Descendant } from 'slate'; +import { + MX_EMOTICON_MD_END, + MX_EMOTICON_MD_SEP, + MX_EMOTICON_MD_START, + validateMxcUrl, +} from '$plugins/markdown/extensions/matrix-emoticon'; import { BlockType } from './types'; import type { ParagraphElement } from './slate'; +import { createEmoticonElement } from './utils'; + +/** Matches placeholders emitted by htmlToMarkdown for <img data-mx-emoticon>. */ +const MX_EMOTICON_MD_TOKEN = new RegExp( + `${MX_EMOTICON_MD_START}([^${MX_EMOTICON_MD_SEP}]+)${MX_EMOTICON_MD_SEP}([^${MX_EMOTICON_MD_END}]+)${MX_EMOTICON_MD_END}`, + 'g' +); + +function mergeAdjacentTextNodes( + children: ParagraphElement['children'] +): ParagraphElement['children'] { + const out: ParagraphElement['children'] = []; + for (const c of children) { + if ('type' in c) { + out.push(c); + continue; + } + const prev = out[out.length - 1]; + if (prev && !('type' in prev)) { + prev.text += c.text; + } else { + out.push({ ...c }); + } + } + return out.length > 0 ? out : [{ text: '' }]; +} + +function lineToParagraphChildren(line: string): ParagraphElement['children'] { + MX_EMOTICON_MD_TOKEN.lastIndex = 0; + const parts: ParagraphElement['children'] = []; + let last = 0; + let match: RegExpExecArray | null; + while ((match = MX_EMOTICON_MD_TOKEN.exec(line)) !== null) { + if (match.index > last) { + parts.push({ text: line.slice(last, match.index) }); + } + const [, src, shortcode] = match; + if (src && shortcode && validateMxcUrl(src)) { + parts.push(createEmoticonElement(src, shortcode)); + } else if (shortcode) { + parts.push({ text: `:${shortcode.replace(/^:|:$/g, '')}:` }); + } + last = MX_EMOTICON_MD_TOKEN.lastIndex; + } + if (last < line.length) { + parts.push({ text: line.slice(last) }); + } + return mergeAdjacentTextNodes(parts); +} export const plainToEditorInput = (text: string): Descendant[] => { - const editorNodes: Descendant[] = text.split('\n').map((lineText) => { - const paragraphNode: ParagraphElement = { - type: BlockType.Paragraph, - children: [ - { - text: lineText, - }, - ], - }; - return paragraphNode; - }); - return editorNodes; + const lines = text.split('\n'); + return lines.map((lineText) => ({ + type: BlockType.Paragraph, + children: lineToParagraphChildren(lineText), + })); }; diff --git a/src/app/plugins/markdown/bidirectional.test.ts b/src/app/plugins/markdown/bidirectional.test.ts index b7aa44d5b..dff56737c 100644 --- a/src/app/plugins/markdown/bidirectional.test.ts +++ b/src/app/plugins/markdown/bidirectional.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import { encodeMxEmoticonForMarkdownPlaceholder } from './extensions/matrix-emoticon'; import { markdownToHtml } from './markdownToHtml'; import { htmlToMarkdown } from './htmlToMarkdown'; import { injectDataMd } from './injectDataMd'; @@ -108,12 +109,14 @@ describe('bidirectional round-trip', () => { expect(result).toContain('k.'); }); - it('round-trips img[data-mx-emoticon] tags', () => { + it('preserves mx emoticons as editor placeholders (mxc URI + shortcode)', () => { const html = ':blobcat:'; const injected = injectDataMd(html); const result = htmlToMarkdown(injected); - expect(result).toContain(' { @@ -81,4 +88,32 @@ describe('htmlToMarkdown', () => { const result = htmlToMarkdown('

Hello *world*

'); expect(result).toContain('\\*'); }); + + it('encodes mx emoticons as private-use placeholders instead of literal img snippets', () => { + const src = 'mxc://matrix.org/emote'; + const html = `

hiblobcatbye

`; + const md = htmlToMarkdown(html); + expect(md).not.toContain(' { + const src = 'mxc://matrix.org/emote'; + const md = `before${MX_EMOTICON_MD_START}${src}${MX_EMOTICON_MD_SEP}blobcat${MX_EMOTICON_MD_END}after`; + const doc = plainToEditorInput(md); + expect(doc).toHaveLength(1); + const p = doc[0] as { type: BlockType; children: unknown[] }; + expect(p.type).toBe(BlockType.Paragraph); + expect(p.children).toEqual([ + { text: 'before' }, + expect.objectContaining({ + type: BlockType.Emoticon, + key: src, + shortcode: 'blobcat', + }), + { text: 'after' }, + ]); + }); }); diff --git a/src/app/plugins/markdown/htmlToMarkdown.ts b/src/app/plugins/markdown/htmlToMarkdown.ts index f6835a1dc..dfdb16fca 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.ts @@ -1,7 +1,10 @@ import parse from 'html-dom-parser'; import type { ChildNode, Element } from 'domhandler'; import { isText, isTag } from 'domhandler'; -import { validateMxcUrl } from './extensions/matrix-emoticon'; +import { + encodeMxEmoticonForMarkdownPlaceholder, + validateMxcUrl, +} from './extensions/matrix-emoticon'; import { escapeMarkdownInlineSequences } from './utils'; /** @@ -338,11 +341,16 @@ function processImage(node: Element): string { } const src = node.attribs.src ?? ''; - const alt = node.attribs.alt ?? ''; + const alt = node.attribs.alt ?? node.attribs.title ?? ''; if (!validateMxcUrl(src)) { return ''; } - return `${alt}`; + const shortcode = alt.replace(/^:|:$/g, ''); + if (!shortcode) { + return ''; + } + + return encodeMxEmoticonForMarkdownPlaceholder(src, shortcode); }