web+native: sidebar previews and notifications render the markdown plain projection, never raw text (#962)
Paul's report: the bubble renders 'Testing *asterisks*' but the sidebar's last-message preview shows 'Testing \*asterisks\*' — the composer's opt-out escapes leak as visible backslashes on every one-line surface that used raw payload text. Same bug on web previews and native push notifications (NSE). New twin projection: plainText (markdown.js) / markdownPlainText (MarkdownTree.swift) — parse to the shared tree, keep the characters the bubble shows, drop the marks (escapes resolve, **bold** → bold, links → their label, blocks join with spaces). Applied at web previewText, native previewText (ThreadStore → Mac sidebar + iOS thread list), and the NSE body. The markdown vectors gain an optional 'plain' field pinning the pair — seven representative cases (escape, marks, code, link, list, fence) asserted by both suites; fixture copy re-synced.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
9 files changed,
+99
−4
ios/LChatCore/Sources/LChatCore/MarkdownTree.swift+33 −0ios/LChatCore/Sources/LChatCore/Models.swift+4 −1ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json+7 −0ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift+5 −0ios/LChatNSE/NotificationService.swift+4 −1test/event-vectors/markdown.json+7 −0test/markdown.test.mjs+4 −1web/static/markdown.js+30 −0web/static/shell.js+5 −1
diff --git a/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift| index bd90dad..e911904 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MarkdownTree.swift |
| @@ -39,6 +39,39 @@ public enum MDBlock: Equatable, Hashable { |
| case codeBlock(text: String, lang: String) |
| } |
| +// MARK: - Plain projection |
| + |
| +// Markdown-aware PLAIN projection for one-line surfaces (the sidebar's |
| +// last-message preview, notification bodies): escapes resolve (\* → *) and |
| +// the marks themselves drop — the same characters the bubbles show, minus |
| +// the styling. Never raw message text on those surfaces: the composer's |
| +// opt-out escapes leak as visible backslashes (Paul's report, 2026-07-15). |
| +// Twin: plainText (web/static/markdown.js); the `plain` fields in the |
| +// markdown vectors pin the pair. |
| +public func markdownPlainText(_ text: String) -> String { |
| + func inline(_ children: [MDInline]) -> String { |
| + children.map { node in |
| + switch node { |
| + case .text(let s), .code(let s): return s |
| + case .br: return " " |
| + case .link(_, let c), .strong(let c), .em(let c), .strike(let c): return inline(c) |
| + } |
| + }.joined() |
| + } |
| + func blocks(_ list: [MDBlock]) -> String { |
| + list.map { block in |
| + switch block { |
| + case .p(let c): return inline(c) |
| + case .quote(let c): return blocks(c) |
| + case .ul(let items): return items.map(blocks).joined(separator: " ") |
| + case .ol(_, let items): return items.map(blocks).joined(separator: " ") |
| + case .codeBlock(let text, _): return text |
| + } |
| + }.joined(separator: " ") |
| + } |
| + return blocks(markdownTree(text)) |
| +} |
| + |
| // MARK: - Parse + walk |
| public func markdownTree(_ text: String) -> [MDBlock] { |
diff --git a/ios/LChatCore/Sources/LChatCore/Models.swift b/ios/LChatCore/Sources/LChatCore/Models.swift| index 7a82cf9..0079fa7 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/Models.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/Models.swift |
| @@ -396,7 +396,10 @@ public struct ChatThread: Identifiable, Equatable, Codable { |
| // A one-line snippet for the sidebar: "Name: text" in groups, just text in DMs — |
| // mirrors previewText in web/static/chat.js. |
| public func previewText(kind: String, payload: MessagePayload) -> String { |
| - var body = (payload.text ?? "") |
| + // Through the markdown plain projection, never raw: raw text leaks the |
| + // composer's opt-out escapes as visible backslashes (\*lit\*), and shows |
| + // the marks the bubble renders as styling. |
| + var body = markdownPlainText(payload.text ?? "") |
| .replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression) |
| .trimmingCharacters(in: .whitespacesAndNewlines) |
| if body.isEmpty, let a = payload.attachments, !a.isEmpty { |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json| index 6f65bb9..00cf9e7 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json |
| @@ -4,6 +4,7 @@ |
| { |
| "name": "plain_text", |
| "input": "hello world", |
| + "plain": "hello world", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -30,6 +31,7 @@ |
| { |
| "name": "bold", |
| "input": "**b**", |
| + "plain": "b", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -201,6 +203,7 @@ |
| { |
| "name": "inline_code", |
| "input": "`co*de*`", |
| + "plain": "co*de*", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -483,6 +486,7 @@ |
| { |
| "name": "explicit_link", |
| "input": "[docs](https://example.com/a)", |
| + "plain": "docs", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -814,6 +818,7 @@ |
| { |
| "name": "bullet_list", |
| "input": "- a\n- b", |
| + "plain": "a b", |
| "tree": [ |
| { |
| "t": "ul", |
| @@ -1205,6 +1210,7 @@ |
| { |
| "name": "fenced_code", |
| "input": "```js\ncode **here**\n```", |
| + "plain": "code **here**", |
| "tree": [ |
| { |
| "t": "codeblock", |
| @@ -1330,6 +1336,7 @@ |
| { |
| "name": "backslash_escape", |
| "input": "\\*lit\\*", |
| + "plain": "*lit*", |
| "tree": [ |
| { |
| "t": "p", |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift b/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift| index 6f6cad5..a4c0693 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift |
| @@ -16,6 +16,7 @@ final class MarkdownVectorTests: XCTestCase { |
| let name: String |
| let input: String |
| let tree: [MDBlock] |
| + let plain: String? // pins markdownPlainText (previews/notifications) when present |
| let note: String? |
| } |
| @@ -48,6 +49,10 @@ final class MarkdownVectorTests: XCTestCase { |
| expected: \(json(v.tree)) |
| got: \(json(got)) |
| """) |
| + if let plain = v.plain { |
| + XCTAssertEqual(markdownPlainText(v.input), plain, |
| + "vector '\(v.name)' plain projection diverged from the web twin") |
| + } |
| } |
| } |
diff --git a/ios/LChatNSE/NotificationService.swift b/ios/LChatNSE/NotificationService.swift| index 2a20c3a..9be4467 100644 |
| --- a/ios/LChatNSE/NotificationService.swift |
| +++ b/ios/LChatNSE/NotificationService.swift |
| @@ -98,7 +98,10 @@ final class NotificationService: UNNotificationServiceExtension { |
| } |
| let sender = senderName(payload.uid, payload.name) ?? "Someone" |
| - var text = payload.text ?? "" |
| + // Plain projection, not raw text — a notification body shows the |
| + // composer's opt-out escapes and markdown marks otherwise (same rule |
| + // as the sidebar previews). |
| + var text = markdownPlainText(payload.text ?? "") |
| if text.isEmpty { |
| let n = payload.attachments?.count ?? 0 |
| text = n > 0 ? (n == 1 ? "📎 Attachment" : "📎 \(n) attachments") : "New message" |
diff --git a/test/event-vectors/markdown.json b/test/event-vectors/markdown.json| index 6f65bb9..00cf9e7 100644 |
| --- a/test/event-vectors/markdown.json |
| +++ b/test/event-vectors/markdown.json |
| @@ -4,6 +4,7 @@ |
| { |
| "name": "plain_text", |
| "input": "hello world", |
| + "plain": "hello world", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -30,6 +31,7 @@ |
| { |
| "name": "bold", |
| "input": "**b**", |
| + "plain": "b", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -201,6 +203,7 @@ |
| { |
| "name": "inline_code", |
| "input": "`co*de*`", |
| + "plain": "co*de*", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -483,6 +486,7 @@ |
| { |
| "name": "explicit_link", |
| "input": "[docs](https://example.com/a)", |
| + "plain": "docs", |
| "tree": [ |
| { |
| "t": "p", |
| @@ -814,6 +818,7 @@ |
| { |
| "name": "bullet_list", |
| "input": "- a\n- b", |
| + "plain": "a b", |
| "tree": [ |
| { |
| "t": "ul", |
| @@ -1205,6 +1210,7 @@ |
| { |
| "name": "fenced_code", |
| "input": "```js\ncode **here**\n```", |
| + "plain": "code **here**", |
| "tree": [ |
| { |
| "t": "codeblock", |
| @@ -1330,6 +1336,7 @@ |
| { |
| "name": "backslash_escape", |
| "input": "\\*lit\\*", |
| + "plain": "*lit*", |
| "tree": [ |
| { |
| "t": "p", |
diff --git a/test/markdown.test.mjs b/test/markdown.test.mjs| index 27b8ffb..b5a6bce 100644 |
| --- a/test/markdown.test.mjs |
| +++ b/test/markdown.test.mjs |
| @@ -10,7 +10,7 @@ import assert from "node:assert/strict"; |
| import { readFileSync } from "node:fs"; |
| import { dirname, join } from "node:path"; |
| import { fileURLToPath } from "node:url"; |
| -import { parseMessage, houseParser } from "../web/static/markdown.js"; |
| +import { parseMessage, houseParser, plainText } from "../web/static/markdown.js"; |
| const __dirname = dirname(fileURLToPath(import.meta.url)); |
| @@ -35,6 +35,9 @@ await test("vector file has substantial coverage", () => { |
| for (const v of vectors) { |
| await test(`vector: ${v.name}`, () => { |
| assert.deepEqual(parseMessage(v.input), v.tree, v.note || ""); |
| + // Vectors carrying a `plain` field also pin the plain projection (the |
| + // sidebar-preview / notification text) on both platforms. |
| + if (v.plain !== undefined) assert.strictEqual(plainText(v.input), v.plain); |
| }); |
| } |
diff --git a/web/static/markdown.js b/web/static/markdown.js| index e1d4087..812f8cc 100644 |
| --- a/web/static/markdown.js |
| +++ b/web/static/markdown.js |
| @@ -245,6 +245,36 @@ export function parseMessage(text) { |
| const WRAP_TAG = { strong: "strong", em: "em", strike: "s" }; |
| // hooks.plain(target, text): render a plain text run — chat.js passes its |
| +// Markdown-aware PLAIN projection for one-line surfaces (the sidebar's |
| +// last-message preview, notification bodies): escapes resolve (\* → *) and |
| +// the marks themselves drop — the same characters the bubbles show, minus |
| +// the styling. Never raw message text on those surfaces: the composer's |
| +// opt-out escapes leak as visible backslashes (Paul's report, 2026-07-15). |
| +// Twin: markdownPlainText (MarkdownTree.swift); the `plain` fields in |
| +// test/event-vectors/markdown.json pin the pair. |
| +export function plainText(text) { |
| + const inline = (children) => |
| + children |
| + .map((n) => { |
| + if (n.t === "text" || n.t === "code") return n.text; |
| + if (n.t === "br") return " "; |
| + if (n.children) return inline(n.children); // link / strong / em / strike |
| + return ""; |
| + }) |
| + .join(""); |
| + const blocks = (list) => |
| + list |
| + .map((b) => { |
| + if (b.t === "p") return inline(b.children); |
| + if (b.t === "quote") return blocks(b.children); |
| + if (b.t === "ul" || b.t === "ol") return b.items.map(blocks).join(" "); |
| + if (b.t === "codeblock") return b.text; |
| + return ""; |
| + }) |
| + .join(" "); |
| + return blocks(parseMessage(text)); |
| +} |
| + |
| // mention scanner; default is a bare text node. Mentions are scanned only on |
| // text runs, never inside code or links, matching the original feed. |
| export function renderTree(el, blocks, hooks = {}) { |
diff --git a/web/static/shell.js b/web/static/shell.js| index 3ad1eb4..b7b12e4 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -51,6 +51,7 @@ import { richTokens, blockTokens, firstLink, replySnippet, formatBytes, autoGrou |
| import { emojify, emojiMatches } from "/static/emoji.js"; |
| import { startBootScene, finishBootScene } from "/static/bootscene.js"; |
| import { processImage, videoMeta } from "/static/media.js"; |
| +import { plainText as mdPlainText } from "/static/markdown.js"; |
| import { createIntegrity } from "/static/integrity.js"; |
| import { |
| registerFloorCommand, |
| @@ -2428,7 +2429,10 @@ window.lchatPaletteRun = (kind, id) => { |
| // A one-line snippet for the chat list: "Name: text" in groups, just text in DMs. |
| function previewText(t, data) { |
| - let body = (data.text || "").replace(/\s+/g, " ").trim(); |
| + // Through the markdown plain projection, never raw: raw text leaks the |
| + // composer's opt-out escapes as visible backslashes (\*lit\*), and shows |
| + // the marks the bubble renders as styling. |
| + let body = mdPlainText(data.text || "").replace(/\s+/g, " ").trim(); |
| // The feed shows @mentions as the viewer's own names for people (#577); the |
| // plain-text preview says the same thing (also rides into native notify). |
| body = translateMentions(body, nicknameRenames()); |