eleven / ocho Public

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>
Paul Campbell 47a7799db8897892dba227067e27e350fcb5ffcd parent 5937555
9 files changed, +99 −4
  • ios/LChatCore/Sources/LChatCore/MarkdownTree.swift +33 −0
  • ios/LChatCore/Sources/LChatCore/Models.swift +4 −1
  • ios/LChatCore/Tests/LChatCoreTests/Fixtures/markdown-vector.json +7 −0
  • ios/LChatCore/Tests/LChatCoreTests/MarkdownVectorTests.swift +5 −0
  • ios/LChatNSE/NotificationService.swift +4 −1
  • test/event-vectors/markdown.json +7 −0
  • test/markdown.test.mjs +4 −1
  • web/static/markdown.js +30 −0
  • web/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());