Commit 6d6a4c34bdc1
tarball16 files changed+289-83
@@ -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",@@ -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": {@@ -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
<button1657
+ 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
+ <button16561670
type="submit"16571671
class="primary-btn"16581672
disabled={(!composer.trim() && currentStaged.length === 0) ||@@ -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); }@@ -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}@@ -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>@@ -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}@@ -1,7 +1,7 @@
11
// Backend-agnostic API surface. Every call forwards to whichever transport22
// 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 = false44
+) => 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 = false106112
): 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
});@@ -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;@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');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 segments72
- .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
-}@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');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
+}@@ -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 = false98
+): 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(),@@ -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
+}@@ -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<y<br><code>a&b</code><br><pre><code class="language-js"><p></code></pre>'37
- );38
-});@@ -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 <x></code></p>');14
+ assert.equal(15
+ formatBody('```js\nlet a = "<b>";\n```', [], true),16
+ '<pre><code class="language-js">let a = "<b>";\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><b>hi</b> <strong>x</strong></p>');22
+ assert.equal(formatBody('<script>alert(1)</script>', [], true), '<script>alert(1)</script>');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
+});@@ -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<b:example.org">A<B</a> <code>A<B</code>'36
+ formatBody('hey A<B `A<B`', mentions, true),37
+ '<p>hey <a href="https://matrix.to/#/@a<b:example.org">A<B</a> <code>A<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<b:example.org">A<B</a>');40
+ assert.equal(formatBody('nobody here', mentions, true), null);4041
});4142
4243
test('markdown pills in the plain body become names', () => {