atagen

malice

public
Star0
Watch0

Commit 6d6a4c34bdc1

tarball

atagen <boss@atagen.co>2026-10-24 06:51 UTC

16 files changed+289-83
web: compose in markdown and render formatted bodies

The composer now runs marked over the body when the markdown toggle is on, escaping raw HTML and linking mentions in text tokens only so code stays literal. Received messages render from formatted_body through an allowlist sanitiser into a node tree instead of {@html}, so formatting from other clients shows up and nothing outside the Matrix tag set reaches the DOM. Enter inside an unclosed fence inserts a newline.
Modified file: web/package-lock.json+13 additions
@@ -10,6 +10,7 @@
1010 "dependencies": {
1111 "@twemoji/svg": "^15.0.0",
1212 "emojibase-data": "^18.0.0",
13+ "marked": "^18.1.1",
1314 "svelte": "^5.39.0"
1415 },
1516 "devDependencies": {
@@ -2702,6 +2703,18 @@
27022703 "@jridgewell/sourcemap-codec": "^1.6.0"
27032704 }
27042705 },
2706+ "node_modules/marked": {
2707+ "version": "18.1.1",
2708+ "resolved": "https://registry.npmjs.org/marked/-/marked-18.1.1.tgz",
2709+ "integrity": "sha512-zEGgrtUG8crk+TrMacJSVABU4DYDDkS8uqgU3ohrn/2fW9C2AEfUrO3m6IhpIpQpl7YcVpiXXC9cSMWDANT3dg==",
2710+ "license": "MIT",
2711+ "bin": {
2712+ "marked": "bin/marked.js"
2713+ },
2714+ "engines": {
2715+ "node": ">= 20"
2716+ }
2717+ },
27052718 "node_modules/min-indent": {
27062719 "version": "1.0.1",
27072720 "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
Modified file: web/package.json+1 additions
@@ -16,6 +16,7 @@
1616 "dependencies": {
1717 "@twemoji/svg": "^15.0.0",
1818 "emojibase-data": "^18.0.0",
19+ "marked": "^18.1.1",
1920 "svelte": "^5.39.0"
2021 },
2122 "devDependencies": {
Modified file: web/src/App.svelte+20 additions−6 deletions
@@ -47,6 +47,7 @@
4747 } from './lib/store';
4848 import { discardPending, pendingByRoom, queueSend, resetPending, retryPending } from './lib/pending';
4949 import type { PendingMessage } from './lib/pending';
50+ import { insideOpenFence } from './lib/markdown';
5051 import { mentionQueryAt, mentionsFromHtml, rankPeople, resolveMentions } from './lib/mentions';
5152 import type { Mention, MentionQuery } from './lib/mentions';
5253 import { loadQuickReactions, quickReactions, saveQuickReactions } from './lib/quick-reactions';
@@ -59,7 +60,7 @@
5960 } from './lib/link-previews';
6061 import { notifyTyping, stopTyping } from './lib/typing';
6162 import { formatDay } from './lib/format';
62- import { lastSpaceRooms, lineHeight, messageSpacing, navFolded, railFolded } from './lib/appearance';
63+ import { lastSpaceRooms, lineHeight, markdownEnabled, messageSpacing, navFolded, railFolded } from './lib/appearance';
6364 import SettingsPanel from './lib/SettingsPanel.svelte';
6465 import { packsForRoom, parseEncryptionState, parseOwnProfile, parseVerificationState, contentPreview, senderLabel } from './lib/types';
6566 import type { MemberSummary, OwnProfile, Person, QuickReaction, ReplyReference, RoomSummary, SendState, TimelineEvent, TimelineItem } from './lib/types';
@@ -194,7 +195,7 @@
194195 send_state: state,
195196 content: {
196197 kind: 'Message',
197- message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: null, edited: false, media: null, link_previews: [] }
198+ message: { kind: 'Text', body: p.body, geo_uri: null, formatted_body: p.formatted, edited: false, media: null, link_previews: [] }
198199 },
199200 reply_to: p.replyTo,
200201 thread_root: null,
@@ -592,7 +593,7 @@
592593 pickedMentions = { ...pickedMentions, [roomId]: [] };
593594 mentionQuery = null;
594595 if (attachments.length === 0) {
595- queueSend(roomId, body, reply, mentions);
596+ queueSend(roomId, body, reply, mentions, $markdownEnabled);
596597 } else {
597598 const { [roomId]: _sent, ...rest } = staged;
598599 staged = rest;
@@ -614,7 +615,7 @@
614615 for (const attachment of attachments) {
615616 uploadName = attachment.file.name || 'attachment';
616617 try {
617- if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions);
618+ if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, $markdownEnabled);
618619 else await uploadAttachment(roomId, attachment.file);
619620 first = false;
620621 } catch (e) {
@@ -687,7 +688,7 @@
687688 const body = composer.trim();
688689 cancelEdit();
689690 if (!body || body === original.trim()) return;
690- editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? [])).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
691+ editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), $markdownEnabled).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
691692 }
692693
693694 function editLastOwn(): boolean {
@@ -787,7 +788,7 @@
787788 return;
788789 }
789790 }
790- if (ev.key === 'Enter' && !ev.shiftKey) {
791+ if (ev.key === 'Enter' && !ev.shiftKey && !insideOpenFence(composer, composerEl?.selectionStart ?? composer.length)) {
791792 ev.preventDefault();
792793 send();
793794 } else if (ev.key === 'Escape' && editing) {
@@ -1653,6 +1654,19 @@
16531654 disabled={!($selectedRoomId && openReady[$selectedRoomId])}
16541655 ></textarea>
16551656 <button
1657+ type="button"
1658+ class="icon-btn md-toggle"
1659+ aria-pressed={$markdownEnabled}
1660+ aria-label={$markdownEnabled ? 'Turn markdown off' : 'Turn markdown on'}
1661+ title={$markdownEnabled ? 'Markdown on' : 'Markdown off'}
1662+ on:click={() => markdownEnabled.update((v) => !v)}
1663+ >
1664+ <svg viewBox="0 0 24 16" width="22" height="15" aria-hidden="true">
1665+ <rect x="1" y="1" width="22" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6" />
1666+ <path d="M4.5 11.5v-7l2.75 3 2.75-3v7M15.5 4.5v6.5M13 8.5l2.5 2.75L18 8.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linejoin="round" />
1667+ </svg>
1668+ </button>
1669+ <button
16561670 type="submit"
16571671 class="primary-btn"
16581672 disabled={(!composer.trim() && currentStaged.length === 0) ||
Modified file: web/src/app.css+2 additions
@@ -784,3 +784,5 @@ input, textarea { font: inherit; }
784784 .rail-btn { width: 40px; height: 40px; font-size: 16px; }
785785 .empty-cta { flex-direction: column; }
786786 }
787+.md-toggle { align-self: center; padding: 4px 6px; }
788+.md-toggle[aria-pressed='true'] { color: var(--accent); }
Modified file: web/src/lib/MessageRow.svelte+1 additions−1 deletions
@@ -347,7 +347,7 @@
347347 {@const m = event.content.message}
348348 {#if stripped.text}
349349 <div class="text" class:emote={m.kind === 'Emote'} class:notice={m.kind === 'Notice' || m.kind === 'ServerNotice'}>
350- {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />
350+ {#if m.kind === 'Emote'}* {displayName} {/if}<MessageText text={stripped.text} html={m.formatted_body} jumbo={m.kind === 'Text'} {mentions} {self} nameOf={(id) => people[id]?.name ?? null} />
351351 {#if m.edited && !showSender}<span class="edited" title="Edited">(edited)</span>{/if}
352352 </div>
353353 {/if}
Modified file: web/src/lib/MessageText.svelte+37 additions−8 deletions
@@ -1,34 +1,38 @@
11 <script lang="ts">
22 import EmojiText from './EmojiText.svelte';
3+ import RichNodes from './RichNodes.svelte';
34 import { parseCode } from './code';
45 import { segmentInline } from './inline';
56 import type { Mention } from './mentions';
7+ import { parseRich } from './rich';
68
79 export let text: string;
10+ export let html: string | null = null;
811 export let jumbo = false;
912 export let mentions: Mention[] = [];
1013 export let self: string | null = null;
1114 export let nameOf: (userId: string) => string | null = () => null;
1215
16+ $: rich = html ? parseRich(html) : null;
1317 $: segments = parseCode(text);
1418 $: pieces = segments.map((s) => (s.kind === 'text' ? segmentInline(s.text, mentions) : []));
1519 $: single = segments.length === 1 && pieces[0].length === 1;
1620 </script>
1721
18-{#each segments as s, i}{#if s.kind === 'inline'}<code class="inline">{s.text}</code>{:else if s.kind === 'block'}<pre class="block"><code>{s.text}</code></pre>{:else}{#each pieces[i] as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text === p.user_id ? (nameOf(p.user_id) ?? p.text) : p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer">{p.text}</a>{:else}<EmojiText text={p.text} jumbo={jumbo && single} />{/if}{/each}{/if}{/each}
22+{#if rich}<div class="rich"><RichNodes nodes={rich} {mentions} {self} {nameOf} /></div>{:else}{#each segments as s, i}{#if s.kind === 'inline'}<code class="inline">{s.text}</code>{:else if s.kind === 'block'}<pre class="block"><code>{s.text}</code></pre>{:else}{#each pieces[i] as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text === p.user_id ? (nameOf(p.user_id) ?? p.text) : p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer">{p.text}</a>{:else}<EmojiText text={p.text} jumbo={jumbo && single} />{/if}{/each}{/if}{/each}{/if}
1923
2024 <style>
21- code {
25+ code, .rich :global(code) {
2226 font-family: ui-monospace, 'JetBrains Mono', 'Fira Code', Menlo, monospace;
2327 font-size: 0.9em;
2428 }
25- .inline {
29+ .inline, .rich :global(:not(pre) > code) {
2630 padding: 1px 5px;
2731 border-radius: 4px;
2832 background: var(--bg-soft);
2933 border: 1px solid var(--border);
3034 }
31- .block {
35+ .block, .rich :global(pre) {
3236 margin: 4px 0;
3337 padding: 8px 10px;
3438 border-radius: 6px;
@@ -38,17 +42,42 @@
3842 white-space: pre;
3943 line-height: 1.4;
4044 }
41- .mention {
45+ .mention, .rich :global(.mention) {
4246 padding: 0 4px;
4347 border-radius: 4px;
4448 font-weight: 600;
4549 color: var(--accent);
4650 background: color-mix(in srgb, var(--accent) 18%, transparent);
4751 }
48- .mention.me {
52+ .mention.me, .rich :global(.mention.me) {
4953 color: var(--warn-text);
5054 background: color-mix(in srgb, var(--warn) 28%, transparent);
5155 }
52- .link { color: var(--accent); overflow-wrap: anywhere; }
53- .link:hover { text-decoration: underline; }
56+ .link, .rich :global(.link) { color: var(--accent); overflow-wrap: anywhere; }
57+ .link:hover, .rich :global(.link:hover) { text-decoration: underline; }
58+ .rich { white-space: normal; }
59+ .rich :global(:is(p, ul, ol, blockquote, h1, h2, h3, h4, h5, h6, table, details)) { margin: 4px 0; }
60+ .rich :global(:first-child) { margin-top: 0; }
61+ .rich :global(:last-child) { margin-bottom: 0; }
62+ .rich :global(:is(ul, ol)) { padding-left: 22px; }
63+ .rich :global(blockquote) {
64+ padding: 2px 10px;
65+ border-left: 3px solid var(--border);
66+ color: var(--muted);
67+ }
68+ .rich :global(:is(h1, h2, h3, h4, h5, h6)) { line-height: 1.3; }
69+ .rich :global(h1) { font-size: 1.4em; }
70+ .rich :global(h2) { font-size: 1.25em; }
71+ .rich :global(:is(h3, h4, h5, h6)) { font-size: 1.1em; }
72+ .rich :global(hr) { border: none; border-top: 1px solid var(--border); margin: 8px 0; }
73+ .rich :global(table) { border-collapse: collapse; }
74+ .rich :global(:is(th, td)) { border: 1px solid var(--border); padding: 3px 8px; text-align: left; }
75+ .rich :global(.spoiler) {
76+ border-radius: 4px;
77+ background: var(--text);
78+ color: transparent;
79+ cursor: pointer;
80+ }
81+ .rich :global(.spoiler:is(:hover, :focus)) { background: var(--bg-soft); color: inherit; }
82+ .rich :global(.emote) { height: 1.6em; width: auto; vertical-align: middle; }
5483 </style>
Added file: web/src/lib/RichNodes.svelte+16 additions
@@ -0,0 +1,16 @@
1+<script lang="ts">
2+ import EmojiText from './EmojiText.svelte';
3+ import RichNodes from './RichNodes.svelte';
4+ import { mediaUrl } from './api';
5+ import { segmentInline } from './inline';
6+ import type { Mention } from './mentions';
7+ import type { RichNode } from './rich';
8+
9+ export let nodes: RichNode[];
10+ export let mentions: Mention[] = [];
11+ export let self: string | null = null;
12+ export let nameOf: (userId: string) => string | null = () => null;
13+ export let plain = false;
14+</script>
15+
16+{#each nodes as n}{#if n.kind === 'text'}{#if plain}{n.text}{:else}{#each segmentInline(n.text, mentions) as p}{#if p.kind === 'mention'}<span class="mention" class:me={p.user_id === self} title={p.user_id}><EmojiText text={p.text === p.user_id ? (nameOf(p.user_id) ?? p.text) : p.text} /></span>{:else if p.kind === 'link'}<a class="link" href={p.href} target="_blank" rel="noopener noreferrer">{p.text}</a>{:else}<EmojiText text={p.text} />{/if}{/each}{/if}{:else if n.kind === 'mention'}<span class="mention" class:me={n.user_id === self} title={n.user_id}><RichNodes nodes={n.children} plain /></span>{:else if n.kind === 'spoiler'}<span class="spoiler" role="button" tabindex="0"><RichNodes nodes={n.children} {mentions} {self} {nameOf} {plain} /></span>{:else if n.kind === 'emote'}<img class="emote" src={mediaUrl(n.mxc) ?? ''} alt={n.alt} title={n.alt} />{:else if n.tag === 'br'}<br />{:else if n.tag === 'hr'}<hr />{:else if n.tag === 'a'}<a class="link" href={n.attrs['href']} target="_blank" rel="noopener noreferrer"><RichNodes nodes={n.children} plain /></a>{:else}<svelte:element this={n.tag} {...n.attrs}><RichNodes nodes={n.children} {mentions} {self} {nameOf} plain={plain || n.tag === 'code' || n.tag === 'pre'} /></svelte:element>{/if}{/each}
Modified file: web/src/lib/api.ts+13 additions−7 deletions
@@ -1,7 +1,7 @@
11 // Backend-agnostic API surface. Every call forwards to whichever transport
22 // this build was compiled for, hosted (fetch + SSE) or on-device (worker).
33 import type { MemberSummary, RoomSummary } from './types';
4-import { codeToHtml } from './code';
4+import { formatBody } from './markdown';
55 import { mentionedUserIds, type Mention } from './mentions';
66 import { HttpTransport } from './transport-http';
77 import type { SessionInfo, Transport, VerifyAction } from './transport';
@@ -35,8 +35,13 @@ export function getRooms(): Promise<{ rooms: RoomSummary[] }> {
3535
3636 export const openRoom = (room_id: string) => transport.openRoom(room_id);
3737 export const closeRoom = (room_id: string) => transport.closeRoom(room_id);
38-export const sendMessage = (room_id: string, body: string, reply_to: string | null = null, mentions: Mention[] = []) =>
39- transport.sendMessage(room_id, body, codeToHtml(body, mentions), reply_to, mentionedUserIds(mentions));
38+export const sendMessage = (
39+ room_id: string,
40+ body: string,
41+ reply_to: string | null = null,
42+ mentions: Mention[] = [],
43+ markdown = false
44+) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown), reply_to, mentionedUserIds(mentions));
4045 export const paginateRoom = (room_id: string) => transport.paginateRoom(room_id);
4146 export const setTyping = (room_id: string, typing: boolean) => transport.setTyping(room_id, typing);
4247 export const getAccountData = (eventType: string) => transport.getAccountData(eventType);
@@ -55,8 +60,8 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
5560 export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
5661 export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>
5762 transport.reactToMessage(room_id, event_id, key, shortcode);
58-export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = []) =>
59- transport.editMessage(room_id, event_id, body, codeToHtml(body, mentions), mentionedUserIds(mentions));
63+export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], markdown = false) =>
64+ transport.editMessage(room_id, event_id, body, formatBody(body, mentions, markdown), mentionedUserIds(mentions));
6065 export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
6166
6267 export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
@@ -102,11 +107,12 @@ export function uploadAttachment(
102107 file: File,
103108 caption: string | null = null,
104109 reply_to: string | null = null,
105- mentions: Mention[] = []
110+ mentions: Mention[] = [],
111+ markdown = false
106112 ): Promise<void> {
107113 return transport.uploadAttachment(room_id, file, {
108114 caption,
109- formatted_caption: caption ? codeToHtml(caption, mentions) : null,
115+ formatted_caption: caption ? formatBody(caption, mentions, markdown) : null,
110116 reply_to,
111117 mentions: mentionedUserIds(mentions)
112118 });
Modified file: web/src/lib/appearance.ts+1 additions
@@ -28,6 +28,7 @@ const isSpacing = (v: unknown): v is number =>
2828
2929 export const navFolded = persisted('malice.navFolded', false, isBool);
3030 export const railFolded = persisted('malice.railFolded', false, isBool);
31+export const markdownEnabled = persisted('malice.markdown', true, isBool);
3132 export const messageSpacing = persisted('malice.messageSpacing', MESSAGE_SPACING_DEFAULT, isSpacing);
3233
3334 export const LINE_HEIGHT_MIN = 1.1;
Modified file: web/src/lib/code.ts−43 deletions
@@ -1,5 +1,3 @@
1-import type { Mention } from './mentions';
2-
31 export type CodeSegment =
42 | { kind: 'text'; text: string }
53 | { kind: 'inline'; text: string }
@@ -38,44 +36,3 @@ export function parseCode(text: string): CodeSegment[] {
3836 export function hasCode(segments: CodeSegment[]): boolean {
3937 return segments.some((s) => s.kind !== 'text');
4038 }
41-
42-function escapeHtml(text: string): string {
43- return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
44-}
45-
46-function escapeRegExp(text: string): string {
47- return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
48-}
49-
50-function textToHtml(text: string, pattern: RegExp | null, byText: Map<string, string>): string {
51- if (!pattern) return escapeHtml(text).replace(/\n/g, '<br>');
52- let out = '';
53- let last = 0;
54- for (const m of text.matchAll(pattern)) {
55- out += escapeHtml(text.slice(last, m.index)).replace(/\n/g, '<br>');
56- const userId = byText.get(m[0]) ?? m[0];
57- out += `<a href="https://matrix.to/#/${escapeHtml(userId)}">${escapeHtml(m[0])}</a>`;
58- last = m.index + m[0].length;
59- }
60- return out + escapeHtml(text.slice(last)).replace(/\n/g, '<br>');
61-}
62-
63-/** Matrix `formatted_body` for a plain body, or null when it has no code or mentions to format. */
64-export function codeToHtml(body: string, mentions: Mention[] = []): string | null {
65- const segments = parseCode(body);
66- const byText = new Map(mentions.map((m) => [m.text, m.user_id]));
67- const texts = [...byText.keys()].sort((a, b) => b.length - a.length);
68- const pattern = texts.length > 0 ? new RegExp(texts.map(escapeRegExp).join('|'), 'g') : null;
69- const linked = pattern !== null && segments.some((s) => s.kind === 'text' && new RegExp(pattern.source).test(s.text));
70- if (!hasCode(segments) && !linked) return null;
71- return segments
72- .map((s) => {
73- if (s.kind === 'inline') return `<code>${escapeHtml(s.text)}</code>`;
74- if (s.kind === 'block') {
75- const cls = s.lang ? ` class="language-${escapeHtml(s.lang)}"` : '';
76- return `<pre><code${cls}>${escapeHtml(s.text)}</code></pre>`;
77- }
78- return textToHtml(s.text, pattern, byText);
79- })
80- .join('');
81-}
Added file: web/src/lib/markdown.ts+68 additions
@@ -0,0 +1,68 @@
1+import { Marked, type Token, type Tokens } from 'marked';
2+import type { Mention } from './mentions';
3+
4+function escapeHtml(text: string): string {
5+ return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
6+}
7+
8+function escapeRegExp(text: string): string {
9+ return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
10+}
11+
12+function mentionLinker(mentions: Mention[]): (escaped: string) => string {
13+ const byText = new Map(mentions.filter((m) => m.text).map((m) => [escapeHtml(m.text), m.user_id]));
14+ if (byText.size === 0) return (escaped) => escaped;
15+ const names = [...byText.keys()].sort((a, b) => b.length - a.length);
16+ const pattern = new RegExp(names.map(escapeRegExp).join('|'), 'g');
17+ return (escaped) =>
18+ escaped.replace(pattern, (name) => `<a href="https://matrix.to/#/${escapeHtml(byText.get(name)!)}">${name}</a>`);
19+}
20+
21+function textToHtml(text: string, link: (escaped: string) => string): string {
22+ return link(escapeHtml(text)).replace(/\n/g, '<br>');
23+}
24+
25+const PLAIN_INLINE = new Set(['text', 'escape', 'br']);
26+
27+function isPlain(tokens: Token[]): boolean {
28+ const blocks = tokens.filter((t) => t.type !== 'space');
29+ if (blocks.length !== 1 || blocks[0].type !== 'paragraph') return blocks.length === 0;
30+ return (blocks[0] as Tokens.Paragraph).tokens.every((t) => PLAIN_INLINE.has(t.type) && !('tokens' in t && t.tokens));
31+}
32+
33+function markdownToHtml(body: string, link: (escaped: string) => string): string | null {
34+ const marked = new Marked({ gfm: true, breaks: true });
35+ marked.use({
36+ renderer: {
37+ text(token) {
38+ if ('tokens' in token && token.tokens) return false;
39+ return link('escaped' in token && token.escaped ? token.text : escapeHtml(token.text));
40+ },
41+ html(token) {
42+ return escapeHtml(token.text);
43+ },
44+ image(token) {
45+ return `<a href="${escapeHtml(token.href)}">${escapeHtml(token.text || token.href)}</a>`;
46+ },
47+ checkbox(token) {
48+ return token.checked ? '[x] ' : '[ ] ';
49+ }
50+ }
51+ });
52+ const tokens = marked.lexer(body);
53+ if (isPlain(tokens)) return null;
54+ return marked.parser(tokens).trim();
55+}
56+
57+/** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
58+export function formatBody(body: string, mentions: Mention[], markdown: boolean): string | null {
59+ const link = mentionLinker(mentions);
60+ const html = markdown ? markdownToHtml(body, link) : null;
61+ if (html !== null) return html;
62+ const plain = textToHtml(body, link);
63+ return plain === escapeHtml(body).replace(/\n/g, '<br>') ? null : plain;
64+}
65+
66+export function insideOpenFence(text: string, caret: number): boolean {
67+ return (text.slice(0, caret).match(/^\s*```/gm)?.length ?? 0) % 2 === 1;
68+}
Modified file: web/src/lib/pending.ts+14 additions−3 deletions
@@ -1,5 +1,6 @@
11 import { get, writable } from 'svelte/store';
22 import { sendMessage } from './api';
3+import { formatBody } from './markdown';
34 import { timelines } from './store';
45 import type { Mention } from './mentions';
56 import type { ReplyReference, TimelineItem } from './types';
@@ -7,6 +8,7 @@ import type { ReplyReference, TimelineItem } from './types';
78 export interface PendingMessage {
89 id: string;
910 body: string;
11+ formatted: string | null;
1012 replyTo: ReplyReference | null;
1113 status: 'sending' | 'failed';
1214 error: string | null;
@@ -18,6 +20,7 @@ export const pendingByRoom = writable<Record<string, PendingMessage[]>>({});
1820 interface PendingRecord extends PendingMessage {
1921 roomId: string;
2022 mentions: Mention[];
23+ markdown: boolean;
2124 knownIds: Set<string>;
2225 }
2326
@@ -40,7 +43,7 @@ function ownIdentities(items: TimelineItem[]): Set<string> {
4043 }
4144
4245 function toPublic(record: PendingRecord): PendingMessage {
43- return { id: record.id, body: record.body, replyTo: record.replyTo, status: record.status, error: record.error, createdAt: record.createdAt };
46+ return { id: record.id, body: record.body, formatted: record.formatted, replyTo: record.replyTo, status: record.status, error: record.error, createdAt: record.createdAt };
4447 }
4548
4649 function roomList(roomId: string): PendingMessage[] {
@@ -74,7 +77,7 @@ async function attemptSend(record: PendingRecord): Promise<void> {
7477 if (!records.has(record.id)) return;
7578 updateRecord(record.id, { status: 'sending', error: null });
7679 try {
77- await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions);
80+ await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown);
7881 } catch (e) {
7982 updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });
8083 }
@@ -86,13 +89,21 @@ function enqueue(record: PendingRecord): void {
8689 queues.set(record.roomId, next.catch(() => undefined));
8790 }
8891
89-export function queueSend(roomId: string, body: string, replyTo: ReplyReference | null = null, mentions: Mention[] = []): void {
92+export function queueSend(
93+ roomId: string,
94+ body: string,
95+ replyTo: ReplyReference | null = null,
96+ mentions: Mention[] = [],
97+ markdown = false
98+): void {
9099 const record: PendingRecord = {
91100 id: nextId(),
92101 roomId,
93102 body,
103+ formatted: formatBody(body, mentions, markdown),
94104 replyTo,
95105 mentions,
106+ markdown,
96107 status: 'sending',
97108 error: null,
98109 createdAt: Date.now(),
Added file: web/src/lib/rich.ts+68 additions
@@ -0,0 +1,68 @@
1+export type RichNode =
2+ | { kind: 'text'; text: string }
3+ | { kind: 'element'; tag: string; attrs: Record<string, string>; children: RichNode[] }
4+ | { kind: 'mention'; user_id: string; children: RichNode[] }
5+ | { kind: 'spoiler'; children: RichNode[] }
6+ | { kind: 'emote'; mxc: string; alt: string };
7+
8+// The tag allowlist from the Matrix spec's m.room.message HTML section, minus font colours.
9+const ALLOWED = new Set([
10+ 'del', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'p', 'a', 'ul', 'ol', 'sup', 'sub', 'li', 'b', 'i',
11+ 'u', 'strong', 'em', 's', 'strike', 'code', 'hr', 'br', 'div', 'table', 'thead', 'tbody', 'tr', 'th', 'td',
12+ 'caption', 'pre', 'span', 'details', 'summary', 'font'
13+]);
14+const DROPPED = new Set(['mx-reply', 'script', 'style', 'head', 'title', 'iframe', 'object', 'template']);
15+const MATRIX_TO_USER = /^https:\/\/matrix\.to\/#\/(@[^/?]+)/;
16+const SAFE_HREF = /^(https?:|mailto:)/i;
17+
18+function attrsFor(el: Element, tag: string): Record<string, string> | null {
19+ const attrs: Record<string, string> = {};
20+ if (tag === 'a') {
21+ const href = el.getAttribute('href') ?? '';
22+ if (!SAFE_HREF.test(href)) return null;
23+ attrs['href'] = href;
24+ } else if (tag === 'ol') {
25+ const start = Number(el.getAttribute('start'));
26+ if (Number.isInteger(start) && start > 0) attrs['start'] = String(start);
27+ } else if (tag === 'code') {
28+ const lang = /(?:^|\s)(language-[\w+#-]+)/.exec(el.getAttribute('class') ?? '');
29+ if (lang) attrs['class'] = lang[1];
30+ }
31+ return attrs;
32+}
33+
34+function convert(node: Node): RichNode[] {
35+ if (node.nodeType === Node.TEXT_NODE) return node.textContent ? [{ kind: 'text', text: node.textContent }] : [];
36+ if (node.nodeType !== Node.ELEMENT_NODE) return [];
37+ const el = node as Element;
38+ const tag = el.tagName.toLowerCase();
39+ if (DROPPED.has(tag)) return [];
40+ const children = () => [...el.childNodes].flatMap(convert);
41+ if (tag === 'img') {
42+ const src = el.getAttribute('src') ?? '';
43+ const alt = el.getAttribute('alt') ?? el.getAttribute('title') ?? '';
44+ if (src.startsWith('mxc://')) return [{ kind: 'emote', mxc: src, alt }];
45+ return alt ? [{ kind: 'text', text: alt }] : [];
46+ }
47+ if (tag === 'span' && el.hasAttribute('data-mx-spoiler')) return [{ kind: 'spoiler', children: children() }];
48+ if (!ALLOWED.has(tag)) return children();
49+ if (tag === 'a') {
50+ const user = MATRIX_TO_USER.exec(el.getAttribute('href') ?? '');
51+ if (user) {
52+ try {
53+ return [{ kind: 'mention', user_id: decodeURIComponent(user[1]), children: children() }];
54+ } catch {
55+ return children();
56+ }
57+ }
58+ }
59+ const attrs = attrsFor(el, tag);
60+ if (!attrs) return children();
61+ return [{ kind: 'element', tag: tag === 'font' ? 'span' : tag, attrs, children: children() }];
62+}
63+
64+/** A sanitised tree for a message's `formatted_body`, safe to render without `{@html}`. */
65+export function parseRich(html: string): RichNode[] {
66+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
67+ return [...doc.body.childNodes].flatMap(convert);
68+}
Modified file: web/test/code.test.ts+1 additions−11 deletions
@@ -1,10 +1,6 @@
11 import assert from 'node:assert/strict';
22 import { test } from 'node:test';
3-import { codeToHtml, parseCode } from '../src/lib/code.ts';
4-
5-test('plain text has no formatted body', () => {
6- assert.equal(codeToHtml('hello <b>'), null);
7-});
3+import { parseCode } from '../src/lib/code.ts';
84
95 test('splits inline code', () => {
106 assert.deepEqual(parseCode('run `ls -l` now'), [
@@ -30,9 +26,3 @@ test('unclosed backtick is text', () => {
3026 assert.deepEqual(parseCode('a ` b'), [{ kind: 'text', text: 'a ` b' }]);
3127 });
3228
33-test('html escapes and keeps line breaks', () => {
34- assert.equal(
35- codeToHtml('x<y\n`a&b`\n```js\n<p>\n```'),
36- 'x&lt;y<br><code>a&amp;b</code><br><pre><code class="language-js">&lt;p&gt;</code></pre>'
37- );
38-});
Added file: web/test/markdown.test.ts+29 additions
@@ -0,0 +1,29 @@
1+import assert from 'node:assert/strict';
2+import { test } from 'node:test';
3+import { formatBody, insideOpenFence } from '../src/lib/markdown.ts';
4+
5+test('plain text stays unformatted', () => {
6+ assert.equal(formatBody('hello\nthere', [], true), null);
7+ assert.equal(formatBody('**bold**', [], false), null);
8+});
9+
10+test('renders the common chat markdown', () => {
11+ assert.equal(formatBody('*it* **bold** ~~gone~~', [], true), '<p><em>it</em> <strong>bold</strong> <del>gone</del></p>');
12+ assert.equal(formatBody('> quoted\nstill', [], true), '<blockquote>\n<p>quoted<br>still</p>\n</blockquote>');
13+ assert.equal(formatBody('run `ls <x>`', [], true), '<p>run <code>ls &lt;x&gt;</code></p>');
14+ assert.equal(
15+ formatBody('```js\nlet a = "<b>";\n```', [], true),
16+ '<pre><code class="language-js">let a = &quot;&lt;b&gt;&quot;;\n</code></pre>'
17+ );
18+});
19+
20+test('raw html is escaped, not passed through', () => {
21+ assert.equal(formatBody('<b>hi</b> **x**', [], true), '<p>&lt;b&gt;hi&lt;/b&gt; <strong>x</strong></p>');
22+ assert.equal(formatBody('<script>alert(1)</script>', [], true), '&lt;script&gt;alert(1)&lt;/script&gt;');
23+});
24+
25+test('enter stays a newline inside an open fence', () => {
26+ assert.equal(insideOpenFence('```\ncode', 8), true);
27+ assert.equal(insideOpenFence('```\ncode\n```\n', 13), false);
28+ assert.equal(insideOpenFence('say ``` mid line', 16), false);
29+});
Modified file: web/test/mentions.test.ts+5 additions−4 deletions
@@ -1,6 +1,6 @@
11 import assert from 'node:assert/strict';
22 import { test } from 'node:test';
3-import { codeToHtml } from '../src/lib/code.ts';
3+import { formatBody } from '../src/lib/markdown.ts';
44 import { mentionQueryAt, mentionedUserIds, rankPeople, resolveMentions, stripMarkdownPills } from '../src/lib/mentions.ts';
55 import { findUrls } from '../src/lib/urls.ts';
66
@@ -33,10 +33,11 @@ test('keeps picked names still in the body and typed matrix ids', () => {
3333 test('links mentions outside code', () => {
3434 const mentions = [{ user_id: '@a<b:example.org', text: 'A<B' }];
3535 assert.equal(
36- codeToHtml('hey A<B `A<B`', mentions),
37- 'hey <a href="https://matrix.to/#/@a&lt;b:example.org">A&lt;B</a> <code>A&lt;B</code>'
36+ formatBody('hey A<B `A<B`', mentions, true),
37+ '<p>hey <a href="https://matrix.to/#/@a&lt;b:example.org">A&lt;B</a> <code>A&lt;B</code></p>'
3838 );
39- assert.equal(codeToHtml('nobody here', mentions), null);
39+ assert.equal(formatBody('hey A<B', mentions, false), 'hey <a href="https://matrix.to/#/@a&lt;b:example.org">A&lt;B</a>');
40+ assert.equal(formatBody('nobody here', mentions, true), null);
4041 });
4142
4243 test('markdown pills in the plain body become names', () => {