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 };
+}