web: feed bubbles + notices render the CommonMark subset (#531) (#875)
renderRichText and renderNoticeBody now parse through markdown.js (parseMessage → renderTree) instead of the hand-rolled richTokens/ blockTokens passes. Mentions keep their exact chip markup via the plain hook; new bubble/notice CSS covers paragraphs and tight lists.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+38
−74
changelog/2026-07-14-markdown-messages.md+9 −0web/static/apps.js+8 −30web/static/apps/chat.js+6 −44web/static/style.css+15 −0
diff --git a/changelog/2026-07-14-markdown-messages.md b/changelog/2026-07-14-markdown-messages.md| new file mode 100644 |
| index 0000000..d68e74b |
| --- /dev/null |
| +++ b/changelog/2026-07-14-markdown-messages.md |
| @@ -0,0 +1,9 @@ |
| +🤖 Messages now understand real Markdown. Alongside the formatting you already |
| +use — **bold**, *italics*, `code`, ~strikethrough~, > quotes and ``` code |
| +blocks — you can write bulleted and numbered lists, link a word instead of |
| +pasting a bare URL (`[like this](https://…)`), and combine formatting freely: |
| +bold text inside a quote, a link inside bold, nested lists. Formatting is also |
| +more predictable — a bold phrase that ends in a link now renders correctly, |
| +and stray asterisks in ordinary text no longer italicise things by accident. |
| +We keep it deliberately small: things like headings and tables stay plain |
| +text in a chat. |
diff --git a/web/static/apps.js b/web/static/apps.js| index 4629fa1..a98ab8b 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -22,7 +22,7 @@ |
| // reducer shape here is deliberately snapshot-ready. |
| import { BOT_COMMANDS } from "./bot-commands.js"; |
| -import { richTokens } from "./format.js"; |
| +import { parseMessage, renderTree } from "./markdown.js"; |
| const apps = []; |
| const typeOwner = new Map(); // event type → owning app |
| @@ -1486,36 +1486,14 @@ export function motionOK() { |
| // ({ label, run } — minted locally, so they bypass the untrusted-payload |
| // whitelist) sit below. |
| // |
| -// Notice bodies render the same safe inline formatting as chat messages, so a |
| -// command example in `backticks` shows as monospace code (and **bold**, links, |
| -// etc. work too). Built from richTokens as text nodes + a single wrapper element |
| -// each — never innerHTML, so untrusted text (a to-do name in a confirmation) |
| -// stays inert. |
| -const NOTICE_MARK_TAG = { b: "strong", i: "em", s: "s", code: "code" }; |
| +// Notice bodies render the same allowlisted CommonMark subset as chat messages |
| +// (markdown.js, #531), so a command example in `backticks` shows as monospace |
| +// code (and **bold**, links, lists etc. work too). renderTree builds DOM from |
| +// text nodes + wrapper elements — never innerHTML, so untrusted text (a to-do |
| +// name in a confirmation) stays inert. No mention hook here: notices are |
| +// machine-authored, not member prose. |
| function renderNoticeBody(text, el) { |
| - for (const tok of richTokens(text)) { |
| - if (tok.href) { |
| - const a = document.createElement("a"); |
| - a.href = tok.href; |
| - a.target = "_blank"; |
| - a.rel = "noopener noreferrer"; |
| - a.textContent = tok.text; |
| - el.appendChild(a); |
| - } else if (tok.mark === "bi") { |
| - // ***bold italic*** — the one combined mark: em nested inside strong. |
| - const b = document.createElement("strong"); |
| - const i = document.createElement("em"); |
| - i.textContent = tok.text; |
| - b.appendChild(i); |
| - el.appendChild(b); |
| - } else if (tok.mark) { |
| - const w = document.createElement(NOTICE_MARK_TAG[tok.mark]); |
| - w.textContent = tok.text; |
| - el.appendChild(w); |
| - } else { |
| - el.appendChild(document.createTextNode(tok.text)); |
| - } |
| - } |
| + renderTree(el, parseMessage(text)); |
| } |
| function fillFullNotice(row, spec = {}, opts = {}) { |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 7dd18ad..352dee8 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -23,7 +23,8 @@ |
| // This is the DEFAULT chat experience for group/DM threads; a device can |
| // switch back per-device (⋮ menu → "Use original chat" — see chat.js). |
| import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, renderSurfaces, commandCatalog, buildLocalNotice, renderApp, deliverAnswer, parseMeCommand, defaultCommandFor, appChatReadOnly, setCommandPanelHost, notifyCommandPanel, motionOK } from "../apps.js"; |
| -import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| +import { replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| +import { parseMessage, renderTree } from "../markdown.js"; |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| import { fileIcon, shouldSend, composerToken, ownMessage, sameSender } from "../ui.js"; |
| @@ -55,7 +56,6 @@ const TIME_SEPARATOR_GAP_MS = 15 * 60 * 1000; // mirrors the original feed's sep |
| // so a single late/dropped beat doesn't blink the indicator out. |
| const TYPING_INTERVAL_MS = 10000; |
| const TYPING_EXPIRY_S = 15; |
| -const MARK_TAG = { b: "strong", i: "em", s: "s", code: "code" }; |
| const coarse = typeof matchMedia !== "undefined" && matchMedia("(pointer: coarse)").matches; |
| // Per-thread presence: name → epoch ms until which they count as typing. |
| @@ -1792,8 +1792,9 @@ function attachSwipeReply(wrap, m, ctx) { |
| wrap.addEventListener("touchcancel", () => onEnd(true)); |
| } |
| -// Rich text exactly like the original feed: block structure first (``` fences, |
| -// > quotes), then inline links/marks, then @mentions inside the plain runs — |
| +// Rich text through the allowlisted CommonMark subset (markdown.js, #531): |
| +// parseMessage builds the vector-pinned semantic tree, renderTree walks it into |
| +// DOM nodes, and @mentions are scanned inside the plain runs via the hook — |
| // same class names (.mention / .mention-link), textContent everywhere. |
| function escapeRegExp(s) { |
| return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); |
| @@ -1845,46 +1846,7 @@ function renderRichText(el, text) { |
| } |
| if (last < t.length) target.appendChild(document.createTextNode(t.slice(last))); |
| }; |
| - const inline = (target, t) => { |
| - for (const tok of richTokens(t)) { |
| - if (tok.href) { |
| - const a = document.createElement("a"); |
| - a.href = tok.href; |
| - a.target = "_blank"; |
| - a.rel = "noopener noreferrer"; |
| - a.textContent = tok.text; |
| - target.appendChild(a); |
| - } else if (tok.mark === "bi") { |
| - // ***bold italic*** — the one combined mark: em nested inside strong. |
| - const b = document.createElement("strong"); |
| - const i = document.createElement("em"); |
| - i.textContent = tok.text; |
| - b.appendChild(i); |
| - target.appendChild(b); |
| - } else if (tok.mark) { |
| - const w = document.createElement(MARK_TAG[tok.mark]); |
| - w.textContent = tok.text; |
| - target.appendChild(w); |
| - } else { |
| - plain(target, tok.text); |
| - } |
| - } |
| - }; |
| - for (const b of blockTokens(text)) { |
| - if (b.type === "code") { |
| - const pre = document.createElement("pre"); |
| - const code = document.createElement("code"); |
| - code.textContent = b.text; |
| - pre.appendChild(code); |
| - el.appendChild(pre); |
| - } else if (b.type === "quote") { |
| - const q = document.createElement("blockquote"); |
| - inline(q, b.text); |
| - el.appendChild(q); |
| - } else { |
| - inline(el, b.text); |
| - } |
| - } |
| + renderTree(el, parseMessage(text), { plain }); |
| } |
| // A download chip for a non-image attachment — the original .file-chip markup. |
diff --git a/web/static/style.css b/web/static/style.css| index ed062f9..632a438 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -551,8 +551,17 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-left-color: color-mix(in srgb, var(--bubble-me-ink) 55%, transparent); |
| color: color-mix(in srgb, var(--bubble-me-ink) 85%, transparent); |
| } |
| +/* Markdown blocks (#531): paragraphs and lists inside bubbles. Lists render |
| + tight always (the parse tree erases loose/tight), so item paragraphs are |
| + flush; nested lists indent from their <li>. */ |
| +.msg .bubble p { margin: 6px 0; } |
| +.msg .bubble ul, .msg .bubble ol { margin: 6px 0; padding-left: 22px; } |
| +.msg .bubble li { margin: 2px 0; } |
| +.msg .bubble li p, .msg .bubble li ul, .msg .bubble li ol { margin: 0; } |
| /* blocks flush with the bubble's own padding at the edges */ |
| +.msg .bubble p:first-child, .msg .bubble ul:first-child, .msg .bubble ol:first-child, |
| .msg .bubble pre:first-child, .msg .bubble blockquote:first-child { margin-top: 0; } |
| +.msg .bubble p:last-child, .msg .bubble ul:last-child, .msg .bubble ol:last-child, |
| .msg .bubble pre:last-child, .msg .bubble blockquote:last-child { margin-bottom: 0; } |
| .msg.me { align-self: flex-end; align-items: flex-end; } |
| .msg.me .bubble { background: var(--bubble-me); color: var(--bubble-me-ink); border-bottom-right-radius: 6px; } |
| @@ -1006,6 +1015,12 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| border-radius: 5px; |
| } |
| .local-notice-body a { color: var(--accent); } |
| +/* Markdown blocks in notice bodies (#531) — same tight-list treatment as bubbles */ |
| +.local-notice-body p, .local-notice-body ul, .local-notice-body ol { margin: 6px 0; } |
| +.local-notice-body ul, .local-notice-body ol { padding-left: 22px; } |
| +.local-notice-body li p, .local-notice-body li ul, .local-notice-body li ol { margin: 0; } |
| +.local-notice-body p:first-child, .local-notice-body ul:first-child, .local-notice-body ol:first-child { margin-top: 0; } |
| +.local-notice-body p:last-child, .local-notice-body ul:last-child, .local-notice-body ol:last-child { margin-bottom: 0; } |
| /* Quoted machine output on a notice (a provider's error): a quotation, set off |
| with a rule and muted — readable prose framing above, evidence below. Not |
| monospaced: it's a quote, not code. */ |