From 62c53b3aa158f5eeadd29ac2963b25b654f09ddd Mon Sep 17 00:00:00 2001 From: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Date: Thu, 16 Jul 2026 20:12:54 -0400 Subject: [PATCH] fix(desktop): ignore mentions in Markdown code Co-authored-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta Signed-off-by: npub1mprnacetjua2xx3p5eddmhxyk6wv929ymm5py8kd2xfxurxahspqqlgyta --- .../src/features/messages/lib/hasMention.ts | 125 +++++++++++++++++- .../messages/lib/useMentions.test.mjs | 43 +++++- 2 files changed, 166 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index 9a1f87c31..5e5bafd8a 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -5,6 +5,129 @@ function escapeRegExp(str: string): string { return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } +function maskRange( + chars: string[], + text: string, + start: number, + end: number, +): void { + for (let index = start; index < end; index += 1) { + if (text[index] !== "\n" && text[index] !== "\r") chars[index] = " "; + } +} + +/** + * Replace Markdown code with spaces while retaining offsets and line endings. + * Handles fenced blocks, four-space/tab-indented lines, and backtick code spans. + */ +function maskMarkdownCode(text: string): string { + const chars = text.split(""); + const lines: Array<{ start: number; end: number; content: string }> = []; + + let lineStart = 0; + while (lineStart < text.length) { + let lineEnd = lineStart; + while ( + lineEnd < text.length && + text[lineEnd] !== "\n" && + text[lineEnd] !== "\r" + ) { + lineEnd += 1; + } + lines.push({ + start: lineStart, + end: lineEnd, + content: text.slice(lineStart, lineEnd), + }); + if (text[lineEnd] === "\r" && text[lineEnd + 1] === "\n") lineEnd += 1; + lineStart = lineEnd + 1; + } + + let fence: { marker: string; length: number } | null = null; + for (const line of lines) { + if (fence) { + maskRange(chars, text, line.start, line.end); + const closing = line.content.match(/^ {0,3}(`+|~+)[ \t]*$/); + if ( + closing && + closing[1][0] === fence.marker && + closing[1].length >= fence.length + ) { + fence = null; + } + continue; + } + + const opening = line.content.match(/^ {0,3}(`{3,}|~{3,})(.*)$/); + if (opening && !(opening[1][0] === "`" && opening[2].includes("`"))) { + fence = { marker: opening[1][0], length: opening[1].length }; + maskRange(chars, text, line.start, line.end); + continue; + } + + if (/^(?: {4}|\t)/.test(line.content)) { + maskRange(chars, text, line.start, line.end); + } + } + + const isMasked = (index: number) => + chars[index] === " " && text[index] !== " "; + const isEscaped = (index: number) => { + let slashCount = 0; + for ( + let cursor = index - 1; + cursor >= 0 && text[cursor] === "\\"; + cursor -= 1 + ) { + slashCount += 1; + } + return slashCount % 2 === 1; + }; + + for (let index = 0; index < text.length; ) { + if (text[index] !== "`" || isMasked(index) || isEscaped(index)) { + index += 1; + continue; + } + + let openerEnd = index + 1; + while ( + openerEnd < text.length && + text[openerEnd] === "`" && + !isMasked(openerEnd) + ) { + openerEnd += 1; + } + const delimiterLength = openerEnd - index; + let closer = openerEnd; + + while (closer < text.length) { + if (text[closer] !== "`" || isMasked(closer)) { + closer += 1; + continue; + } + let closerEnd = closer + 1; + while ( + closerEnd < text.length && + text[closerEnd] === "`" && + !isMasked(closerEnd) + ) { + closerEnd += 1; + } + if (closerEnd - closer === delimiterLength) { + maskRange(chars, text, index, closerEnd); + index = closerEnd; + break; + } + closer = closerEnd; + } + + if (closer >= text.length) index = openerEnd; + } + + return chars.join(""); +} + /** * Check whether `text` contains an @mention of `name`. * @@ -23,7 +146,7 @@ export function getMentionOffset(text: string, name: string): number | null { `(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`, "i", ); - const match = pattern.exec(text); + const match = pattern.exec(maskMarkdownCode(text)); return match ? match.index + match[1].length : null; } diff --git a/desktop/src/features/messages/lib/useMentions.test.mjs b/desktop/src/features/messages/lib/useMentions.test.mjs index 634e4f99d..aeb0b7d86 100644 --- a/desktop/src/features/messages/lib/useMentions.test.mjs +++ b/desktop/src/features/messages/lib/useMentions.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { hasMention } from "./hasMention.ts"; +import { getMentionOffset, hasMention } from "./hasMention.ts"; // ── Plain @mention ──────────────────────────────────────────────────── @@ -94,3 +94,44 @@ test("does not false-positive on partial name match", () => { // "Al" should not match inside "@Alice" assert.equal(hasMention("@Alice", "Al"), false); }); + +// ── Markdown code ───────────────────────────────────────────────────── + +test("ignores mentions in inline code", () => { + assert.equal(hasMention("run `notify @Alice now`", "Alice"), false); + assert.equal(hasMention("run ``notify `x` @Alice``", "Alice"), false); +}); + +test("ignores mentions in fenced code blocks", () => { + assert.equal( + hasMention("before\n```ts\nnotify(@Alice)\n```\nafter", "Alice"), + false, + ); + assert.equal(hasMention("~~~\r\n@Alice\r\n~~~", "Alice"), false); +}); + +test("ignores mentions in indented code blocks", () => { + assert.equal(hasMention("before\n @Alice\nafter", "Alice"), false); + assert.equal(hasMention("before\n\t@Alice\nafter", "Alice"), false); +}); + +test("still matches prose mentions around code", () => { + assert.equal(hasMention("`@Alice` then @Alice", "Alice"), true); + assert.equal(hasMention("```\n@Alice\n```\n@Alice", "Alice"), true); + assert.equal(hasMention(" @Alice\n@Alice", "Alice"), true); +}); + +test("preserves the original offset after masked code", () => { + const text = "`@Alice` then @Alice"; + assert.equal(getMentionOffset(text, "Alice"), text.lastIndexOf("@Alice")); +}); + +test("does not treat escaped or unclosed backticks as code", () => { + assert.equal(hasMention("\\` @Alice", "Alice"), true); + assert.equal(hasMention("` @Alice", "Alice"), true); +}); + +test("requires matching inline-code delimiter lengths", () => { + assert.equal(hasMention("`` @Alice ` still code ``", "Alice"), false); + assert.equal(hasMention("`` @Alice `", "Alice"), true); +});