atagen

malice

public
Star0
Watch0

Commit e822c659890f

tarball

atagen <boss@atagen.co>2026-10-25 02:38 UTC

9 files changed+118-79
composer: enable discord style markdown for all messages
Modified file: web/package-lock.json+20 additions−18 deletions
@@ -8,9 +8,9 @@
88 "name": "malice-web",
99 "version": "0.1.0",
1010 "dependencies": {
11+ "@cloudrac3r/discord-markdown": "2.9.1",
1112 "@twemoji/svg": "^15.0.0",
1213 "emojibase-data": "^18.0.0",
13- "marked": "^18.1.1",
1414 "svelte": "^5.39.0"
1515 },
1616 "devDependencies": {
@@ -67,6 +67,15 @@
6767 "node": ">=6.9.0"
6868 }
6969 },
70+ "node_modules/@cloudrac3r/discord-markdown": {
71+ "version": "2.9.1",
72+ "resolved": "https://registry.npmjs.org/@cloudrac3r/discord-markdown/-/discord-markdown-2.9.1.tgz",
73+ "integrity": "sha512-sYaDfRBaS7uMVQx4wJwT68F6OzJZX84DF2XvHkQr8vdwTCvnpeaT26obqvzNawVwVoQ4xjIMZxafgMrAEK4Oxw==",
74+ "license": "MIT",
75+ "dependencies": {
76+ "simple-markdown": "^0.7.3"
77+ }
78+ },
7079 "node_modules/@emnapi/core": {
7180 "version": "1.9.2",
7281 "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz",
@@ -2077,9 +2086,7 @@
20772086 "version": "19.3.0",
20782087 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
20792088 "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
2080- "dev": true,
20812089 "license": "MIT",
2082- "peer": true,
20832090 "dependencies": {
20842091 "csstype": "^3.2.2"
20852092 }
@@ -2315,9 +2322,7 @@
23152322 "version": "3.2.3",
23162323 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
23172324 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2318- "dev": true,
2319- "license": "MIT",
2320- "peer": true
2325+ "license": "MIT"
23212326 },
23222327 "node_modules/debug": {
23232328 "version": "4.4.3",
@@ -2703,18 +2708,6 @@
27032708 "@jridgewell/sourcemap-codec": "^1.6.0"
27042709 }
27052710 },
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- },
27182711 "node_modules/min-indent": {
27192712 "version": "1.0.1",
27202713 "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
@@ -3097,6 +3090,15 @@
30973090 "node": ">=10"
30983091 }
30993092 },
3093+ "node_modules/simple-markdown": {
3094+ "version": "0.7.3",
3095+ "resolved": "https://registry.npmjs.org/simple-markdown/-/simple-markdown-0.7.3.tgz",
3096+ "integrity": "sha512-uGXIc13NGpqfPeFJIt/7SHHxd6HekEJYtsdoCM06mEBPL9fQH/pSD7LRM6PZ7CKchpSvxKL4tvwMamqAaNDAyg==",
3097+ "license": "MIT",
3098+ "dependencies": {
3099+ "@types/react": ">=16.0.0"
3100+ }
3101+ },
31003102 "node_modules/source-map": {
31013103 "version": "0.6.1",
31023104 "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
Modified file: web/package.json+1 additions−1 deletions
@@ -14,9 +14,9 @@
1414 "build-storybook": "storybook build && chmod -R u+w storybook-static"
1515 },
1616 "dependencies": {
17+ "@cloudrac3r/discord-markdown": "2.9.1",
1718 "@twemoji/svg": "^15.0.0",
1819 "emojibase-data": "^18.0.0",
19- "marked": "^18.1.1",
2020 "svelte": "^5.39.0"
2121 },
2222 "devDependencies": {
Modified file: web/src/App.svelte+5 additions−19 deletions
@@ -88,7 +88,6 @@
8888 SIDE_WIDTH,
8989 lastSpaceRooms,
9090 lineHeight,
91- markdownEnabled,
9291 membersWidth,
9392 messageSpacing,
9493 navFolded,
@@ -729,7 +728,7 @@
729728 if (image?.previewUrl) URL.revokeObjectURL(image.previewUrl);
730729 });
731730 } else if (attachments.length === 0) {
732- queueSend(roomId, text, reply, mentions, $markdownEnabled, resolveEmotes(text, roomEmotes));
731+ queueSend(roomId, text, reply, mentions, resolveEmotes(text, roomEmotes));
733732 } else {
734733 const { [roomId]: _sent, ...rest } = staged;
735734 staged = rest;
@@ -771,7 +770,7 @@
771770 case 'me':
772771 case 'notice': {
773772 const said = required('something to say');
774- await sendMessage(roomId, said, null, mentions, $markdownEnabled, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));
773+ await sendMessage(roomId, said, null, mentions, name === 'me' ? 'emote' : 'notice', resolveEmotes(said, roomEmotes));
775774 break;
776775 }
777776 case 'msg': {
@@ -779,7 +778,7 @@
779778 const res = (await runCommand({ op: 'direct', user_id })) as { room_id: string };
780779 await refreshRooms();
781780 await selectRoom(res.room_id);
782- if (rest) queueSend(res.room_id, rest, null, [], $markdownEnabled);
781+ if (rest) queueSend(res.room_id, rest);
783782 break;
784783 }
785784 case 'join':
@@ -881,7 +880,7 @@
881880 for (const attachment of attachments) {
882881 uploadName = attachment.file.name || 'attachment';
883882 try {
884- if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, $markdownEnabled, resolveEmotes(body, roomEmotes));
883+ if (first) await uploadAttachment(roomId, attachment.file, body || null, reply?.event_id ?? null, mentions, resolveEmotes(body, roomEmotes));
885884 else await uploadAttachment(roomId, attachment.file);
886885 first = false;
887886 } catch (e) {
@@ -954,7 +953,7 @@
954953 const body = composer.trim();
955954 cancelEdit();
956955 if (!body || body === original.trim()) return;
957- editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), $markdownEnabled, resolveEmotes(body, roomEmotes)).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
956+ editMessage(roomId, eventId, body, resolveMentions(body, pickedMentions[roomId] ?? []), resolveEmotes(body, roomEmotes)).catch((e) => pushBanner(e instanceof Error ? e.message : 'Edit failed'));
958957 }
959958
960959 function editLastOwn(): boolean {
@@ -2614,19 +2613,6 @@
26142613 ></textarea>
26152614 </div>
26162615 <button
2617- type="button"
2618- class="icon-btn"
2619- aria-pressed={$markdownEnabled}
2620- aria-label={$markdownEnabled ? 'Turn markdown off' : 'Turn markdown on'}
2621- title={$markdownEnabled ? 'Markdown on' : 'Markdown off'}
2622- on:click={() => markdownEnabled.update((v) => !v)}
2623- >
2624- <svg viewBox="0 0 24 16" width="21" height="14" aria-hidden="true">
2625- <rect x="1" y="1" width="22" height="14" rx="2" fill="none" stroke="currentColor" stroke-width="1.6" />
2626- <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" />
2627- </svg>
2628- </button>
2629- <button
26302616 type="submit"
26312617 class="primary-btn"
26322618 disabled={(!composer.trim() && currentStaged.length === 0) ||
Modified file: web/src/lib/api.ts+4 additions−6 deletions
@@ -41,10 +41,9 @@ export const sendMessage = (
4141 body: string,
4242 reply_to: string | null = null,
4343 mentions: Mention[] = [],
44- markdown = false,
4544 kind: SendKind = 'text',
4645 emotes: Emote[] = []
47-) => transport.sendMessage(room_id, body, formatBody(body, mentions, markdown, emotes), reply_to, mentionedUserIds(mentions), kind);
46+) => transport.sendMessage(room_id, body, formatBody(body, mentions, true, emotes), reply_to, mentionedUserIds(mentions), kind);
4847 export const runCommand = (request: CommandRequest) => transport.command(request);
4948 export const uploadAvatar = (file: File) => transport.uploadAvatar(file);
5049 export const uploadRoomAvatar = (room_id: string, file: File) => transport.uploadRoomAvatar(room_id, file);
@@ -73,8 +72,8 @@ export function createRoom(name: string, topic?: string): Promise<{ room_id: str
7372 export const leaveRoom = (room_id: string) => transport.leaveRoom(room_id);
7473 export const reactToMessage = (room_id: string, event_id: string, key: string, shortcode?: string) =>
7574 transport.reactToMessage(room_id, event_id, key, shortcode);
76-export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], markdown = false, emotes: Emote[] = []) =>
77- transport.editMessage(room_id, event_id, body, formatBody(body, mentions, markdown, emotes), mentionedUserIds(mentions));
75+export const editMessage = (room_id: string, event_id: string, body: string, mentions: Mention[] = [], emotes: Emote[] = []) =>
76+ transport.editMessage(room_id, event_id, body, formatBody(body, mentions, true, emotes), mentionedUserIds(mentions));
7877 export const markRead = (room_id: string, event_id: string) => transport.markRead(room_id, event_id);
7978
8079 export function getMembers(room_id: string): Promise<{ members: MemberSummary[] }> {
@@ -121,12 +120,11 @@ export function uploadAttachment(
121120 caption: string | null = null,
122121 reply_to: string | null = null,
123122 mentions: Mention[] = [],
124- markdown = false,
125123 emotes: Emote[] = []
126124 ): Promise<void> {
127125 return transport.uploadAttachment(room_id, file, {
128126 caption,
129- formatted_caption: caption ? formatBody(caption, mentions, markdown, emotes) : null,
127+ formatted_caption: caption ? formatBody(caption, mentions, true, emotes) : null,
130128 reply_to,
131129 mentions: mentionedUserIds(mentions)
132130 });
Modified file: web/src/lib/appearance.ts−1 deletions
@@ -28,7 +28,6 @@ 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);
3231
3332 export const SIDE_WIDTH = { min: 200, max: 480, default: 260 };
3433 export const MEMBERS_WIDTH = { min: 200, max: 420, default: 240 };
Added file: web/src/lib/discord-markdown.d.ts+7 additions
@@ -0,0 +1,7 @@
1+declare module '@cloudrac3r/discord-markdown' {
2+ import type { ParserRule, SingleASTNode } from 'simple-markdown';
3+
4+ export type MarkdownNode = SingleASTNode;
5+ export const rules: Record<string, ParserRule>;
6+ export const markdownEngine: Pick<typeof import('simple-markdown'), 'parserFor' | 'sanitizeUrl'>;
7+}
Modified file: web/src/lib/markdown.ts+77 additions−27 deletions
@@ -1,4 +1,4 @@
1-import { Marked, type Token, type Tokens } from 'marked';
1+import { markdownEngine, rules, type MarkdownNode } from '@cloudrac3r/discord-markdown';
22 import type { Emote } from './emotes';
33 import type { Mention } from './mentions';
44
@@ -33,36 +33,86 @@ function textToHtml(text: string, link: (escaped: string) => string): string {
3333 return link(escapeHtml(text)).replace(/\n/g, '<br>');
3434 }
3535
36-const PLAIN_INLINE = new Set(['text', 'escape', 'br']);
36+const parseMarkdown = markdownEngine.parserFor(
37+ Object.fromEntries(Object.entries(rules).filter(([name]) => !name.startsWith('discord')))
38+);
39+const INLINE_TAGS: Record<string, string> = { em: 'em', strong: 'strong', u: 'u', strike: 'del', small: 'sub' };
40+const BLOCK_TYPES = new Set(['blockQuote', 'codeBlock', 'heading', 'list', 'paragraph']);
3741
38-function isPlain(tokens: Token[]): boolean {
39- const blocks = tokens.filter((t) => t.type !== 'space');
40- if (blocks.length !== 1 || blocks[0].type !== 'paragraph') return blocks.length === 0;
41- return (blocks[0] as Tokens.Paragraph).tokens.every((t) => PLAIN_INLINE.has(t.type) && !('tokens' in t && t.tokens));
42+function renderMarkdown(nodes: MarkdownNode[], link: (escaped: string) => string): string {
43+ let html = '';
44+ let text = '';
45+ const flushText = () => {
46+ html += textToHtml(text, link);
47+ text = '';
48+ };
49+ for (const node of nodes) {
50+ if (node.type === 'text') {
51+ text += node.content;
52+ continue;
53+ }
54+ flushText();
55+ const children = () => renderMarkdown(node.content, link);
56+ const tag = INLINE_TAGS[node.type];
57+ if (tag) {
58+ html += `<${tag}>${children()}</${tag}>`;
59+ continue;
60+ }
61+ switch (node.type) {
62+ case 'br':
63+ case 'newline':
64+ html += '<br>';
65+ break;
66+ case 'inlineCode':
67+ html += `<code>${escapeHtml(node.content)}</code>`;
68+ break;
69+ case 'codeBlock': {
70+ const lang = /^[\w+#-]+$/.test(node.lang ?? '') ? ` class="language-${escapeHtml(node.lang)}"` : '';
71+ html += `<pre><code${lang}>${escapeHtml(node.content)}\n</code></pre>`;
72+ break;
73+ }
74+ case 'spoiler':
75+ html += `<span data-mx-spoiler>${renderMarkdown(node.parsed, link)}</span>`;
76+ break;
77+ case 'blockQuote':
78+ html += `<blockquote>${children()}</blockquote>`;
79+ break;
80+ case 'paragraph':
81+ html += `<p>${children()}</p>`;
82+ break;
83+ case 'heading': {
84+ const level = Math.max(1, Math.min(6, node.level));
85+ html += `<h${level}>${children()}</h${level}>`;
86+ break;
87+ }
88+ case 'list': {
89+ const tag = node.ordered ? 'ol' : 'ul';
90+ const start = node.ordered && Number.isSafeInteger(node.start) && node.start > 0 ? ` start="${node.start}"` : '';
91+ html += `<${tag}${start}>${node.items.map((item: MarkdownNode[]) => `<li>${renderMarkdown(item, link)}</li>`).join('')}</${tag}>`;
92+ break;
93+ }
94+ case 'link':
95+ case 'url':
96+ case 'autolink': {
97+ const label = renderMarkdown(node.content, (escaped) => escaped);
98+ const href = markdownEngine.sanitizeUrl(node.target);
99+ const title = node.title ? ` title="${escapeHtml(node.title)}"` : '';
100+ html += href && /^(https?:|mailto:)/i.test(href) ? `<a href="${escapeHtml(href)}"${title}>${label}</a>` : label;
101+ break;
102+ }
103+ }
104+ }
105+ flushText();
106+ return html;
42107 }
43108
44109 function markdownToHtml(body: string, link: (escaped: string) => string): string | null {
45- const marked = new Marked({ gfm: true, breaks: true });
46- marked.use({
47- renderer: {
48- text(token) {
49- if ('tokens' in token && token.tokens) return false;
50- return link('escaped' in token && token.escaped ? token.text : escapeHtml(token.text));
51- },
52- html(token) {
53- return escapeHtml(token.text);
54- },
55- image(token) {
56- return `<a href="${escapeHtml(token.href)}">${escapeHtml(token.text || token.href)}</a>`;
57- },
58- checkbox(token) {
59- return token.checked ? '[x] ' : '[ ] ';
60- }
61- }
62- });
63- const tokens = marked.lexer(body);
64- if (isPlain(tokens)) return null;
65- return marked.parser(tokens).trim();
110+ const nodes = parseMarkdown(body, { inline: true });
111+ const html = renderMarkdown(nodes, link);
112+ if (html === textToHtml(body, (escaped) => escaped)) return null;
113+ if (nodes.every((node) => node.type === 'text' || node.type === 'br' || node.type === 'newline') ||
114+ nodes.some((node) => BLOCK_TYPES.has(node.type))) return html;
115+ return `<p>${html}</p>`;
66116 }
67117
68118 /** Matrix `formatted_body` for a composed message, or null when plain text says it all. */
Modified file: web/src/lib/pending.ts+2 additions−5 deletions
@@ -21,7 +21,6 @@ export const pendingByRoom = writable<Record<string, PendingMessage[]>>({});
2121 interface PendingRecord extends PendingMessage {
2222 roomId: string;
2323 mentions: Mention[];
24- markdown: boolean;
2524 emotes: Emote[];
2625 knownIds: Set<string>;
2726 }
@@ -79,7 +78,7 @@ async function attemptSend(record: PendingRecord): Promise<void> {
7978 if (!records.has(record.id)) return;
8079 updateRecord(record.id, { status: 'sending', error: null });
8180 try {
82- await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, record.markdown, 'text', record.emotes);
81+ await sendMessage(record.roomId, record.body, record.replyTo?.event_id ?? null, record.mentions, 'text', record.emotes);
8382 } catch (e) {
8483 updateRecord(record.id, { status: 'failed', error: e instanceof Error ? e.message : 'Message could not be sent' });
8584 }
@@ -96,17 +95,15 @@ export function queueSend(
9695 body: string,
9796 replyTo: ReplyReference | null = null,
9897 mentions: Mention[] = [],
99- markdown = false,
10098 emotes: Emote[] = []
10199 ): void {
102100 const record: PendingRecord = {
103101 id: nextId(),
104102 roomId,
105103 body,
106- formatted: formatBody(body, mentions, markdown, emotes),
104+ formatted: formatBody(body, mentions, true, emotes),
107105 replyTo,
108106 mentions,
109- markdown,
110107 emotes,
111108 status: 'sending',
112109 error: null,
Modified file: web/test/markdown.test.ts+2 additions−2 deletions
@@ -9,7 +9,7 @@ test('plain text stays unformatted', () => {
99
1010 test('renders the common chat markdown', () => {
1111 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>');
12+ assert.equal(formatBody('> quoted\nstill', [], true), '<blockquote>quoted<br></blockquote>still');
1313 assert.equal(formatBody('run `ls <x>`', [], true), '<p>run <code>ls &lt;x&gt;</code></p>');
1414 assert.equal(
1515 formatBody('```js\nlet a = "<b>";\n```', [], true),
@@ -19,7 +19,7 @@ test('renders the common chat markdown', () => {
1919
2020 test('raw html is escaped, not passed through', () => {
2121 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;');
22+ assert.equal(formatBody('<script>alert(1)</script>', [], true), null);
2323 });
2424
2525 test('enter stays a newline inside an open fence', () => {