From 60c9db8c5251d837953ed9c254eb1ee92d1b9c16 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 5 May 2026 13:54:14 -0500 Subject: [PATCH 1/2] fix emoticon not properly converting when editing messages --- .changeset/fix-emojis-edits.md | 5 ++ src/app/components/editor/input.ts | 72 +++++++++++++++---- .../markdown/extensions/matrix-emoticon.ts | 9 +++ .../plugins/markdown/htmlToMarkdown.test.ts | 35 +++++++++ src/app/plugins/markdown/htmlToMarkdown.ts | 14 +++- 5 files changed, 120 insertions(+), 15 deletions(-) create mode 100644 .changeset/fix-emojis-edits.md 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/extensions/matrix-emoticon.ts b/src/app/plugins/markdown/extensions/matrix-emoticon.ts index a340358b5..12e8bd007 100644 --- a/src/app/plugins/markdown/extensions/matrix-emoticon.ts +++ b/src/app/plugins/markdown/extensions/matrix-emoticon.ts @@ -1,5 +1,14 @@ import type { TokenizerExtension, RendererExtension } from 'marked'; +/** Delimiters for round-tripping Matrix emoticons from HTML through markdown into the Slate composer. */ +export const MX_EMOTICON_MD_START = '\uE000'; +export const MX_EMOTICON_MD_SEP = '\uE001'; +export const MX_EMOTICON_MD_END = '\uE002'; + +export function encodeMxEmoticonForMarkdownPlaceholder(src: string, shortcode: string): string { + return `${MX_EMOTICON_MD_START}${src}${MX_EMOTICON_MD_SEP}${shortcode}${MX_EMOTICON_MD_END}`; +} + /** * Validates that a URL is a proper mxc:// URI. * Returns true if valid, false otherwise. diff --git a/src/app/plugins/markdown/htmlToMarkdown.test.ts b/src/app/plugins/markdown/htmlToMarkdown.test.ts index c12561863..ca61ee776 100644 --- a/src/app/plugins/markdown/htmlToMarkdown.test.ts +++ b/src/app/plugins/markdown/htmlToMarkdown.test.ts @@ -1,4 +1,11 @@ import { describe, expect, it } from 'vitest'; +import { + MX_EMOTICON_MD_END, + MX_EMOTICON_MD_SEP, + MX_EMOTICON_MD_START, +} from './extensions/matrix-emoticon'; +import { plainToEditorInput } from '$components/editor/input'; +import { BlockType } from '$components/editor/types'; import { htmlToMarkdown } from './htmlToMarkdown'; describe('htmlToMarkdown', () => { @@ -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); } From e935fe07178cc0e479278369b68b4b7c11e25aad Mon Sep 17 00:00:00 2001 From: 7w1 Date: Tue, 5 May 2026 14:20:15 -0500 Subject: [PATCH 2/2] fix test --- src/app/plugins/markdown/bidirectional.test.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) 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('