eleven / messenger Public

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>
Paul Campbell b98f7b6af01b82257480597361e60ce441f17aa8 parent 7b0fc4c
4 files changed, +38 −74
  • changelog/2026-07-14-markdown-messages.md +9 −0
  • web/static/apps.js +8 −30
  • web/static/apps/chat.js +6 −44
  • web/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. */