From 4fe27992d27639aa4f91f36c8d5e70382d13a79a Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 10:36:18 -0500 Subject: [PATCH 1/5] fix room pings not looking like @ room --- .changeset/fix-room-ping-label.md | 5 +++ src/app/components/editor/output.test.ts | 51 +++++++++++++++++++++++- src/app/components/editor/output.ts | 7 +++- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-room-ping-label.md diff --git a/.changeset/fix-room-ping-label.md b/.changeset/fix-room-ping-label.md new file mode 100644 index 000000000..9c2d4da7a --- /dev/null +++ b/.changeset/fix-room-ping-label.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fixed room pings looking like normal message links instead of pings. diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index 44a2dddb6..210a8ef8a 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { toMatrixCustomHTML, trimCustomHtml } from '$components/editor/output'; +import { toMatrixCustomHTML, toPlainText, trimCustomHtml } from '$components/editor/output'; import { BlockType } from '$components/editor/types'; describe('toMatrixCustomHTML emoticons', () => { @@ -30,7 +30,31 @@ describe('toMatrixCustomHTML emoticons', () => { }); describe('toMatrixCustomHTML matrix.to', () => { - it('serializes room mentions as raw matrix.to URL text, not an anchor', () => { + it('serializes @room pings as a markdown link so the label is @room, not a bare permalink', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Mention, + id: '!room:example.org', + name: '@room', + children: [{ text: '' }], + } as never, + ], + } as never, + ], + {} + ) + ); + + expect(html).toMatch(/]*href="https:\/\/matrix\.to\/#\/!room:example\.org"/i); + expect(html).toContain('@room'); + }); + + it('serializes non–@room mentions as bare matrix.to URL text', () => { const html = trimCustomHtml( toMatrixCustomHTML( [ @@ -54,6 +78,29 @@ describe('toMatrixCustomHTML matrix.to', () => { expect(html).not.toMatch(/]*matrix\.to/i); }); + it('uses @room in plain body for room pings, not the room id', () => { + const plain = toPlainText( + [ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Mention, + id: '!room:example.org', + name: '@room', + highlight: true, + children: [{ text: '' }], + } as never, + ], + } as never, + ], + false, + undefined + ).trim(); + + expect(plain).toBe('@room'); + }); + it('serializes matrix.to links as raw URL text, not an anchor', () => { const html = trimCustomHtml( toMatrixCustomHTML( diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 9bede4d6e..a0bb034d7 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -41,6 +41,9 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { } const matrixTo = `${MATRIX_TO_BASE}#/${fragment}`; + if (node.name === '@room') { + return `[@room](${encodeURI(matrixTo)})`; + } return sanitizeText(matrixTo); } case BlockType.Emoticon: @@ -113,7 +116,7 @@ const elementToPlainText = (node: CustomElement, children: string): string => { case BlockType.Paragraph: return `${children}\n`; case BlockType.Mention: - return node.id; + return node.name === '@room' ? node.name : node.id; case BlockType.Emoticon: return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; case BlockType.Link: @@ -184,7 +187,7 @@ export const toRawText = (node: Descendant | Descendant[]): string => { case BlockType.Emoticon: return node.key.startsWith('mxc://') ? `:${node.shortcode}:` : node.key; case BlockType.Mention: - return node.id; + return node.name === '@room' ? node.name : node.id; case BlockType.Command: return `/${node.command}`; default: From 801851a17217643a36b8be9405320310d0a2338f Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 14:55:16 -0500 Subject: [PATCH 2/5] fix mentions --- src/app/components/editor/output.test.ts | 62 ++++++++++++++++++- src/app/components/editor/output.ts | 32 ++++++++-- src/app/features/room/RoomInput.tsx | 2 + .../features/room/message/MessageEditor.tsx | 5 +- 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index 210a8ef8a..bc9eeec12 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from 'vitest'; +import type { Room } from '$types/matrix-sdk'; import { toMatrixCustomHTML, toPlainText, trimCustomHtml } from '$components/editor/output'; import { BlockType } from '$components/editor/types'; +const roomWithMember = (userId: string, rawDisplayName: string): Room => + ({ + getMember: (id: string) => + id === userId ? ({ userId: id, rawDisplayName } as never) : undefined, + }) as Room; + describe('toMatrixCustomHTML emoticons', () => { it('always serializes custom emoji images with height=32', () => { const html = trimCustomHtml( @@ -54,7 +61,7 @@ describe('toMatrixCustomHTML matrix.to', () => { expect(html).toContain('@room'); }); - it('serializes non–@room mentions as bare matrix.to URL text', () => { + it('serializes non–@room room mentions as bare matrix.to URL text', () => { const html = trimCustomHtml( toMatrixCustomHTML( [ @@ -78,6 +85,59 @@ describe('toMatrixCustomHTML matrix.to', () => { expect(html).not.toMatch(/]*matrix\.to/i); }); + it('serializes user mentions using room membership display name, not private Slate node.name', () => { + const room = roomWithMember('@alice:example.org', 'Alice'); + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Mention, + id: '@alice:example.org', + name: 'Secret local only nickname', + highlight: true, + children: [{ text: '' }], + } as never, + ], + } as never, + ], + { room } + ) + ); + + expect(html).toMatch(/]*href="https:\/\/matrix\.to\/#\/@alice:example\.org"/i); + expect(html).toContain('Alice'); + expect(html).not.toContain('Secret local only nickname'); + }); + + it('serializes user mentions without room using MXID localpart as link label', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { + type: BlockType.Paragraph, + children: [ + { + type: BlockType.Mention, + id: '@alice:example.org', + name: 'Secret local only nickname', + highlight: true, + children: [{ text: '' }], + } as never, + ], + } as never, + ], + {} + ) + ); + + expect(html).toMatch(/]*href="https:\/\/matrix\.to\/#\/@alice:example\.org"/i); + expect(html).toMatch(/>alice<\/a>/i); + expect(html).not.toContain('Secret local only nickname'); + }); + it('uses @room in plain body for room pings, not the room id', () => { const plain = toPlainText( [ diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index a0bb034d7..1e6fe39eb 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -1,10 +1,11 @@ import type { Descendant, Editor } from 'slate'; import { Text } from 'slate'; -import type { MatrixClient } from '$types/matrix-sdk'; +import type { MatrixClient, Room } from '$types/matrix-sdk'; import { sanitizeText } from '$utils/sanitize'; import { markdownToHtml, injectDataMd } from '$plugins/markdown'; import { sanitizeForRegex } from '$utils/regex'; -import { isUserId } from '$utils/matrix'; +import { getMxIdLocalPart, isUserId } from '$utils/matrix'; +import { getMemberDisplayName } from '$utils/room'; import type { CustomElement } from './slate'; import { BlockType } from './types'; import { getMarkdownCodeSpanRanges, isInsideMarkdownCodeSpan } from './utils'; @@ -21,11 +22,30 @@ export type OutputOptions = { nickNameReplacement?: Map; /** When true, markdown HTML omits the leading `

` wrapper (for `m.emote` / `/me`). */ forEmote?: boolean; + room?: Room; }; const textToCustomHtml = (node: Text): string => sanitizeText(node.text); -const elementToCustomHtml = (node: CustomElement, children: string): string => { +const markdownInlineLinkLabel = (label: string, fallback: string): string => { + const t = label.trim(); + if (!t) return fallback; + if (/[\]\n\r\u0000-\u001f]/.test(t)) return fallback; + return t; +}; + +const userMentionMarkdownLinkLabel = (userId: string, room: Room | undefined): string => { + const fallback = getMxIdLocalPart(userId) ?? userId; + if (!room) return fallback; + const fromMembership = getMemberDisplayName(room, userId); + return markdownInlineLinkLabel(fromMembership ?? '', fallback); +}; + +const elementToCustomHtml = ( + node: CustomElement, + children: string, + opts: OutputOptions +): string => { switch (node.type) { case BlockType.Paragraph: return `${children}
`; @@ -44,6 +64,10 @@ const elementToCustomHtml = (node: CustomElement, children: string): string => { if (node.name === '@room') { return `[@room](${encodeURI(matrixTo)})`; } + if (isUserId(node.id)) { + const label = userMentionMarkdownLinkLabel(node.id, opts.room); + return `[${label}](${encodeURI(matrixTo)})`; + } return sanitizeText(matrixTo); } case BlockType.Emoticon: @@ -108,7 +132,7 @@ export const toMatrixCustomHTML = ( const children = node.children .map((element, index, array) => parseNode(element, index, array)) .join(''); - return elementToCustomHtml(node, children); + return elementToCustomHtml(node, children, opts); }; const elementToPlainText = (node: CustomElement, children: string): string => { diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 69f67248b..adaae6490 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -778,6 +778,7 @@ export const RoomInput = forwardRef( stripNickname: true, nickNameReplacement: nicknameReplacement, forEmote: commandName === Command.Me, + room, }) ); @@ -853,6 +854,7 @@ export const RoomInput = forwardRef( stripNickname: true, nickNameReplacement: nicknameReplacement, forEmote: commandName === Command.Me, + room, }) ); } diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 307d17998..9ac8b85cd 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -173,7 +173,10 @@ export const MessageEditor = as<'div', MessageEditorProps>( const msgtype = mEvent.getContent().msgtype as RoomMessageTextEventContent['msgtype']; let plainText = toPlainText(editor.children).trim(); let customHtml = trimCustomHtml( - toMatrixCustomHTML(editor.children, { forEmote: msgtype === MsgType.Emote }) + toMatrixCustomHTML(editor.children, { + forEmote: msgtype === MsgType.Emote, + room, + }) ); const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody(); From 822b0b898addda49676ac45f6a9b8a48ac197c04 Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 14:57:55 -0500 Subject: [PATCH 3/5] changeset --- .changeset/fix-mentions-not-linked.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-mentions-not-linked.md diff --git a/.changeset/fix-mentions-not-linked.md b/.changeset/fix-mentions-not-linked.md new file mode 100644 index 000000000..cfc77d26b --- /dev/null +++ b/.changeset/fix-mentions-not-linked.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Fix mentions not being linkfied. From 542474b80f8e4dc6b61f95b02beed5ef8cae21bd Mon Sep 17 00:00:00 2001 From: 7w1 Date: Thu, 14 May 2026 15:05:03 -0500 Subject: [PATCH 4/5] markdown header fix --- .changeset/fix-markdown-header-stuff.md | 5 + src/app/components/editor/output.test.ts | 16 ++ .../plugins/markdown/expandBlockNewlines.ts | 154 ++++++++++++++++++ .../plugins/markdown/markdownToHtml.test.ts | 52 ++++++ src/app/plugins/markdown/markdownToHtml.ts | 5 +- 5 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-markdown-header-stuff.md create mode 100644 src/app/plugins/markdown/expandBlockNewlines.ts diff --git a/.changeset/fix-markdown-header-stuff.md b/.changeset/fix-markdown-header-stuff.md new file mode 100644 index 000000000..a91413c60 --- /dev/null +++ b/.changeset/fix-markdown-header-stuff.md @@ -0,0 +1,5 @@ +--- +default: patch +--- + +Made markdown headers also function properly with single new lines instead of only two new lines. diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index bc9eeec12..84b7e6cb4 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -184,3 +184,19 @@ describe('toMatrixCustomHTML matrix.to', () => { expect(html).not.toMatch(/]*matrix\.to/i); }); }); + +describe('toMatrixCustomHTML single-newline markdown blocks', () => { + it('parses -# on a second Slate paragraph joined with a single newline', () => { + const html = trimCustomHtml( + toMatrixCustomHTML( + [ + { type: BlockType.Paragraph, children: [{ text: 'test' }] } as never, + { type: BlockType.Paragraph, children: [{ text: '-# caption' }] } as never, + ], + {} + ) + ); + expect(html).toContain('= minLen) return j; + j += run; + } else { + j++; + } + } + return -1; +} + +function findMultilineFenceEnd( + md: string, + contentStart: number, + tick: string, + minLen: number +): { blockEnd: number; contentEnd: number } | null { + let p = contentStart; + while (p <= md.length) { + const nl = md.indexOf('\n', p); + const lineStart = p; + const lineEnd = nl === -1 ? md.length : nl; + const line = md.slice(lineStart, lineEnd); + const m = tick === '`' ? /^ {0,3}(`{3,})\s*$/.exec(line) : /^ {0,3}(~{3,})\s*$/.exec(line); + const fenceRun = m?.[1]; + if (fenceRun && fenceRun.length >= minLen && fenceRun[0] === tick) { + return { + blockEnd: nl === -1 ? md.length : nl + 1, + contentEnd: lineStart, + }; + } + if (nl === -1) return null; + p = nl + 1; + } + return null; +} + +/** Returns index just past the fence block, or null if `i` is not a fence opener at line start. */ +function trySkipFenceEnd(md: string, i: number): number | null { + const atLineStart = i === 0 || md[i - 1] === '\n'; + if (!atLineStart) return null; + + const rest = md.slice(i); + const open = /^(\s{0,3})(`{3,}|~{3,})/.exec(rest); + if (!open?.[2]) return null; + + const fenceStr = open[2]; + const tick = fenceStr.charAt(0); + const openLen = fenceStr.length; + const afterOpen = i + open[0].length; + + if (afterOpen < md.length && md[afterOpen] === '\n') { + const contentStart = afterOpen + 1; + const close = findMultilineFenceEnd(md, contentStart, tick, openLen); + if (!close) return md.length; + return close.blockEnd; + } + + const closeIdx = findSameLineFenceClose(md, afterOpen, tick, openLen); + if (closeIdx < 0) return null; + + let closeRun = 0; + while (closeIdx + closeRun < md.length && md[closeIdx + closeRun] === tick) closeRun++; + + return closeIdx + closeRun; +} + +function afterLeadingIndent(line: string): string { + let k = 0; + while (k < line.length && k < 3 && line[k] === ' ') k++; + return line.slice(k); +} + +function effectiveContentAfterEscapes(line: string): string | null { + const rest = afterLeadingIndent(line); + if (rest.length === 0) return null; + let bs = 0; + while (bs < rest.length && rest[bs] === '\\') bs++; + if (bs % 2 === 1) return null; + return rest.slice(bs); +} + +function looksLikeBlockStart(effective: string): boolean { + if (effective.length === 0) return false; + + if (/^#{1,6}(?:\s|$)/.test(effective)) return true; + if (/^-# +/.test(effective)) return true; + if (/^>\s/.test(effective)) return true; + if (/^[-*+]\s/.test(effective)) return true; + if (/^\d{1,9}\.\s/.test(effective)) return true; + if (/^(?:`{3,}|~{3,})/.test(effective)) return true; + if (/^\$\$/.test(effective)) return true; + if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(effective)) return true; + + return false; +} + +function nextLineIsBlockStarter(md: string, newlineIdx: number): boolean { + const start = newlineIdx + 1; + if (start >= md.length) return false; + const nextNl = md.indexOf('\n', start); + const line = nextNl === -1 ? md.slice(start) : md.slice(start, nextNl); + const effective = effectiveContentAfterEscapes(line); + if (effective === null) return false; + return looksLikeBlockStart(effective); +} + +/** + * After a single newline (not part of `\n\n`), inserts one more `\n` when the following line + * opens a block the marked stack understands. Fenced code is copied verbatim without changes. + */ +export function expandBlockBoundariesAfterSingleNewlines(markdown: string): string { + const md = markdown.replace(/\r\n/g, '\n'); + let out = ''; + let i = 0; + const n = md.length; + + while (i < n) { + const atLineStart = i === 0 || md[i - 1] === '\n'; + if (atLineStart) { + const fenceEnd = trySkipFenceEnd(md, i); + if (fenceEnd !== null) { + out += md.slice(i, fenceEnd); + i = fenceEnd; + continue; + } + } + + if ( + md[i] === '\n' && + (i === 0 || md[i - 1] !== '\n') && + (i + 1 >= n || md[i + 1] !== '\n') && + nextLineIsBlockStarter(md, i) + ) { + out += '\n\n'; + i += 1; + continue; + } + + out += md[i]; + i += 1; + } + + return out; +} diff --git a/src/app/plugins/markdown/markdownToHtml.test.ts b/src/app/plugins/markdown/markdownToHtml.test.ts index c57d36113..d6e2b2461 100644 --- a/src/app/plugins/markdown/markdownToHtml.test.ts +++ b/src/app/plugins/markdown/markdownToHtml.test.ts @@ -114,6 +114,58 @@ describe('markdownToHtml', () => { expect(html).toContain('fenced'); }); + describe('single-newline block boundaries', () => { + it('parses matrix -# after a single newline following text', () => { + const result = markdownToHtml('test\n-# caption'); + expect(result).toContain(' { + expect(markdownToHtml('intro\n# Heading')).toContain(' { + const html = markdownToHtml('intro\n- item'); + expect(html).toContain(' { + expect(markdownToHtml('intro\n> quote')).toContain('

'); + }); + + it('does not promote -# inside fenced code when the fence follows a single newline', () => { + const html = markdownToHtml('test\n```\n-# not sub\n```'); + expect(html).not.toContain(' { + const html = markdownToHtml('test\n```\ncode\n```\n-# cap'); + expect(html).toContain('
');
+      expect(html).toContain(' {
+      const result = markdownToHtml('test\n\n-# caption');
+      expect(result).toContain(' {
+      const html = markdownToHtml('k. Hello world\nHello again');
+      expect(html).not.toContain('
    '); + }); + + it('does not promote escaped line-start -# after a single newline', () => { + expect(markdownToHtml('x\n\\-# literal')).not.toContain('data-md="-#"'); + }); + }); + it('does not parse escaped \\-# as small/sub', () => { const result = markdownToHtml('\\-# literal caption'); expect(result).not.toContain(' Date: Thu, 14 May 2026 15:16:10 -0500 Subject: [PATCH 5/5] formatting and lint things --- src/app/components/editor/output.test.ts | 2 +- src/app/components/editor/output.ts | 5 ++++- src/app/components/message/content/ImageContent.tsx | 10 ++++++---- src/app/plugins/markdown/expandBlockNewlines.ts | 2 +- src/app/plugins/markdown/markdownToHtml.ts | 2 +- 5 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/app/components/editor/output.test.ts b/src/app/components/editor/output.test.ts index 84b7e6cb4..cd8199c52 100644 --- a/src/app/components/editor/output.test.ts +++ b/src/app/components/editor/output.test.ts @@ -7,7 +7,7 @@ const roomWithMember = (userId: string, rawDisplayName: string): Room => ({ getMember: (id: string) => id === userId ? ({ userId: id, rawDisplayName } as never) : undefined, - }) as Room; + }) as unknown as Room; describe('toMatrixCustomHTML emoticons', () => { it('always serializes custom emoji images with height=32', () => { diff --git a/src/app/components/editor/output.ts b/src/app/components/editor/output.ts index 1e6fe39eb..33baeac15 100644 --- a/src/app/components/editor/output.ts +++ b/src/app/components/editor/output.ts @@ -30,7 +30,10 @@ const textToCustomHtml = (node: Text): string => sanitizeText(node.text); const markdownInlineLinkLabel = (label: string, fallback: string): string => { const t = label.trim(); if (!t) return fallback; - if (/[\]\n\r\u0000-\u001f]/.test(t)) return fallback; + if (t.includes(']')) return fallback; + for (let i = 0; i < t.length; i++) { + if (t.charCodeAt(i) <= 0x1f) return fallback; + } return t; }; diff --git a/src/app/components/message/content/ImageContent.tsx b/src/app/components/message/content/ImageContent.tsx index 13868c4bf..9904fa9f3 100644 --- a/src/app/components/message/content/ImageContent.tsx +++ b/src/app/components/message/content/ImageContent.tsx @@ -144,7 +144,7 @@ export const ImageContent = as<'div', ImageContentProps>( useEffect(() => { if (!viewer) { setViewerFullSrc(null); - return; + return undefined; } if ( typeof matrixThumbnailMaxEdge !== 'number' || @@ -152,7 +152,7 @@ export const ImageContent = as<'div', ImageContentProps>( encInfo || url.startsWith('http') ) { - return; + return undefined; } let cancelled = false; void (async () => { @@ -182,7 +182,9 @@ export const ImageContent = as<'div', ImageContentProps>( if (autoPlay) loadSrc(); }, [autoPlay, loadSrc]); - const hasDimensions = typeof info?.w === 'number' && typeof info?.h === 'number'; + const imageW = info?.w; + const imageH = info?.h; + const hasDimensions = typeof imageW === 'number' && typeof imageH === 'number'; const isContained = mediaLayout === 'contained'; const fillsSlot = Boolean(fillsPreviewSlot && isContained); const containedReserveStrip = @@ -200,7 +202,7 @@ export const ImageContent = as<'div', ImageContentProps>( : isContained ? { minHeight: containedReserveStrip ? toRem(stripMin) : undefined } : hasDimensions - ? { aspectRatio: `${info!.w} / ${info!.h}` } + ? { aspectRatio: `${imageW} / ${imageH}` } : { minHeight: '150px' }; const fillPreviewSlotStyle = fillsSlot diff --git a/src/app/plugins/markdown/expandBlockNewlines.ts b/src/app/plugins/markdown/expandBlockNewlines.ts index 617157be0..6c860744e 100644 --- a/src/app/plugins/markdown/expandBlockNewlines.ts +++ b/src/app/plugins/markdown/expandBlockNewlines.ts @@ -98,7 +98,7 @@ function looksLikeBlockStart(effective: string): boolean { if (/^[-*+]\s/.test(effective)) return true; if (/^\d{1,9}\.\s/.test(effective)) return true; if (/^(?:`{3,}|~{3,})/.test(effective)) return true; - if (/^\$\$/.test(effective)) return true; + if (effective.startsWith('$$')) return true; if (/^(?:-{3,}|\*{3,}|_{3,})\s*$/.test(effective)) return true; return false; diff --git a/src/app/plugins/markdown/markdownToHtml.ts b/src/app/plugins/markdown/markdownToHtml.ts index fcb28237d..74b680e11 100644 --- a/src/app/plugins/markdown/markdownToHtml.ts +++ b/src/app/plugins/markdown/markdownToHtml.ts @@ -78,7 +78,7 @@ const shieldBareMatrixToLinks = ( const unshieldBareMatrixToLinks = (html: string, placeholders: Map): string => { let result = html; - const keys = [...placeholders.keys()].sort((a, b) => b.length - a.length); + const keys = [...placeholders.keys()].toSorted((a, b) => b.length - a.length); for (const key of keys) { const url = placeholders.get(key); if (url) result = result.split(key).join(escapeHtml(url));