diff --git a/src/app/features/common-settings/general/RoomProfile.tsx b/src/app/features/common-settings/general/RoomProfile.tsx index 4279e8325..a1e598fe9 100644 --- a/src/app/features/common-settings/general/RoomProfile.tsx +++ b/src/app/features/common-settings/general/RoomProfile.tsx @@ -52,8 +52,7 @@ import { settingsAtom } from '../../../state/settings'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions'; - -const MARKDOWN_PATTERN = /(\*\*|__|\*|_|~~|`|\[.+?\]\(.+?\))/; +import { buildTopicContent } from '../../../utils/topicMarkdown'; function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholder: string) { const start = textarea.selectionStart; @@ -71,30 +70,6 @@ function wrapSelection(textarea: HTMLTextAreaElement, syntax: string, placeholde textarea.setSelectionRange(cursorStart, cursorEnd); } -function topicMarkdownToHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/__(.+?)__/g, '$1') - .replace(/\*(.+?)\*/g, '$1') - .replace(/_(.+?)_/g, '$1') - .replace(/~~(.+?)~~/g, '$1') - .replace(/`(.+?)`/g, '$1') - .replace(/\[(.+?)\]\((.+?)\)/g, '$1') - .replace(/\n/g, '
'); -} - -function buildTopicContent(topic: string): Record { - if (!MARKDOWN_PATTERN.test(topic)) return { topic }; - const formattedBody = topicMarkdownToHtml(topic); - // Use HTML-stripped text as the plain topic so the header shows clean text, not raw markdown syntax - const plainTopic = formattedBody.replace(/
/g, '\n').replace(/<[^>]+>/g, ''); - - return { topic: plainTopic, format: 'org.matrix.custom.html', formatted_body: formattedBody }; -} - type RoomProfileEditProps = { canEditAvatar: boolean; canEditName: boolean; diff --git a/src/app/utils/topicMarkdown.test.ts b/src/app/utils/topicMarkdown.test.ts new file mode 100644 index 000000000..6b98dd830 --- /dev/null +++ b/src/app/utils/topicMarkdown.test.ts @@ -0,0 +1,72 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { buildTopicContent } from './topicMarkdown'; + +test('a URL with underscores is sent verbatim (#262)', () => { + const t = 'Rules: https://wiki.lotusguild.org/en/server_rules_and_faq'; + assert.deepEqual(buildTopicContent(t), { topic: t }); +}); + +test('a markdown link keeps its underscored URL in both fields (#262)', () => { + const c = buildTopicContent('[Our wiki](https://wiki.example.org/my_cool_page)'); + assert.ok('formatted_body' in c); + assert.match(c.formatted_body, /href="https:\/\/wiki\.example\.org\/my_cool_page"/); + assert.equal(c.topic, 'Our wiki (https://wiki.example.org/my_cool_page)'); +}); + +test('room aliases and user IDs with underscores are untouched (#262)', () => { + for (const t of [ + 'Join #my_room_name:matrix.org', + 'Ask @some_user_x:matrix.org', + 'see snake_case_word', + ]) { + assert.deepEqual(buildTopicContent(t), { topic: t }); + } +}); + +test('emphasis still works at word boundaries', () => { + const c = buildTopicContent('this is _really_ *great* and **bold**'); + assert.ok('formatted_body' in c); + assert.match(c.formatted_body, /really<\/i>/); + assert.match(c.formatted_body, /great<\/i>/); + assert.match(c.formatted_body, /bold<\/strong>/); + assert.equal(c.topic, 'this is really great and bold'); +}); + +test('emphasis around an alias keeps the alias intact', () => { + const c = buildTopicContent('**Join #my_room:matrix.org**'); + assert.ok('formatted_body' in c); + assert.match(c.formatted_body, /Join #my_room:matrix\.org<\/strong>/); + assert.equal(c.topic, 'Join #my_room:matrix.org'); +}); + +test('inline code keeps its underscores and no backslashes appear', () => { + const c = buildTopicContent('run `a_b_c` now'); + assert.ok('formatted_body' in c); + assert.match(c.formatted_body, /a_b_c<\/code>/); + assert.equal(c.topic, 'run a_b_c now'); +}); + +test('a double quote in a link target cannot break out of the attribute (#262)', () => { + const c = buildTopicContent('[x](https://a" onclick="alert(1))'); + assert.ok('formatted_body' in c); + assert.doesNotMatch(c.formatted_body, /" onclick="/); +}); + +test('HTML in the topic is escaped, and the plain topic shows it as typed', () => { + const c = buildTopicContent('**hi & bye**'); + assert.ok('formatted_body' in c); + assert.match(c.formatted_body, /<b>hi<\/b> & bye/); + assert.equal(c.topic, 'hi & bye'); +}); + +test('line breaks become
and survive in the plain topic', () => { + const c = buildTopicContent('**Rules**\nBe nice'); + assert.ok('formatted_body' in c); + assert.equal(c.formatted_body, 'Rules
Be nice'); + assert.equal(c.topic, 'Rules\nBe nice'); +}); + +test('plain text with no markdown is sent as typed', () => { + assert.deepEqual(buildTopicContent("It's & games"), { topic: "It's & games" }); +}); diff --git a/src/app/utils/topicMarkdown.ts b/src/app/utils/topicMarkdown.ts new file mode 100644 index 000000000..3f09f8b25 --- /dev/null +++ b/src/app/utils/topicMarkdown.ts @@ -0,0 +1,63 @@ +import { parseInlineMD } from '../plugins/markdown'; +import { sanitizeText } from './sanitize'; + +/** + * Room topic markdown → `m.room.topic` content (#262). + * + * Uses the composer's inline markdown parser (the same rules as messages: links, + * bare URLs and `"` are handled), with one topic-specific guard: an `_` between + * two letters/digits is literal, as in CommonMark. Topics are full of + * `#room_alias:server`, `@user_name:server` and snake_case paths, which the + * shared parser would otherwise turn into italics and, worse, drop from the + * plain `topic` that every other client shows. + */ + +// Spans the guard must not touch: inline code, link targets, bare URLs. +const PROTECTED = /(`[^`\n]*`|\]\([^)\n]*\)|https?:\/\/[^\s<>"]+)/; +const INTRAWORD_UNDERSCORES = /(?<=[\p{L}\p{N}])_+(?=[\p{L}\p{N}])/gu; + +const escapeIntrawordUnderscores = (line: string): string => + line + .split(PROTECTED) + .map((part, i) => + // split() with one capture group puts the protected spans at odd indexes. + i % 2 === 1 ? part : part.replace(INTRAWORD_UNDERSCORES, (m) => m.replace(/_/g, '\\_')), + ) + .join(''); + +const ENTITIES: Record = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + ''': "'", +}; +const decodeEntities = (s: string): string => + s.replace(/&(?:amp|lt|gt|quot|#39);/g, (e) => ENTITIES[e]); + +/** Readable plain text for `topic`: markers dropped, links kept as "text (url)". */ +const htmlToPlain = (html: string): string => + decodeEntities( + html + .replace(/]*\bhref="([^"]*)"[^>]*>(.*?)<\/a>/g, (_m, href: string, label: string) => { + const text = label.replace(/<[^>]+>/g, ''); + return text === href ? href : `${text} (${href})`; + }) + .replace(/
/g, '\n') + .replace(/<[^>]+>/g, ''), + ); + +export type TopicContent = + | { topic: string } + | { topic: string; format: 'org.matrix.custom.html'; formatted_body: string }; + +export function buildTopicContent(text: string): TopicContent { + const lines = text.split('\n'); + const html = lines + .map((line) => parseInlineMD(sanitizeText(escapeIntrawordUnderscores(line)))) + .join('
'); + const plainHtml = lines.map(sanitizeText).join('
'); + // No markdown at all: send the text as typed, without a formatted body. + if (html === plainHtml) return { topic: text }; + return { topic: htmlToPlain(html), format: 'org.matrix.custom.html', formatted_body: html }; +}