From c713c33ebdd896736739ca0001ef67040fbed4bf Mon Sep 17 00:00:00 2001 From: Taewoong Kim <46616734+Foreist@users.noreply.github.com> Date: Thu, 23 Jul 2026 13:38:51 +0900 Subject: [PATCH 1/2] fix(desktop): detect @mentions butted against CJK text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @Name immediately followed by Korean/Japanese/Chinese with no separating space (e.g. @Fizz이렇게) failed the mention boundary regex, so hasMention() returned false, the p-tag was never attached, and the notification never fired. Display names are Latin/ASCII, so a transition into a CJK code point is an unambiguous word boundary; add Hangul/Kana/CJK ranges to the trailing (and leading) boundary in both getMentionOffset and buildPrefixPattern via a shared CJK_BOUNDARY_RANGES constant. Latin over-matching (@Fizzbar) stays blocked. Adds unit tests for both. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../features/messages/lib/hasMention.test.mjs | 46 +++++++++++++++++++ .../src/features/messages/lib/hasMention.ts | 20 ++++---- .../src/shared/lib/mentionPattern.test.mjs | 36 +++++++++++++++ desktop/src/shared/lib/mentionPattern.ts | 16 ++++++- 4 files changed, 106 insertions(+), 12 deletions(-) create mode 100644 desktop/src/features/messages/lib/hasMention.test.mjs create mode 100644 desktop/src/shared/lib/mentionPattern.test.mjs diff --git a/desktop/src/features/messages/lib/hasMention.test.mjs b/desktop/src/features/messages/lib/hasMention.test.mjs new file mode 100644 index 0000000000..63193e3b53 --- /dev/null +++ b/desktop/src/features/messages/lib/hasMention.test.mjs @@ -0,0 +1,46 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { getMentionOffset, hasMention } from "./hasMention.ts"; + +test("matches a plain @mention", () => { + assert.equal(hasMention("hey @Fizz can you help", "Fizz"), true); +}); + +test("matches a mention at end of string", () => { + assert.equal(hasMention("ping @Fizz", "Fizz"), true); +}); + +test("matches a mention immediately followed by Hangul (no space)", () => { + // Regression: the trailing boundary previously required whitespace or ASCII + // punctuation, so `@Fizz` butted against Korean text dropped the p-tag. + assert.equal(hasMention("@Fizz이렇게됨", "Fizz"), true); +}); + +test("matches a mention preceded by Hangul (no space)", () => { + assert.equal(hasMention("안녕@Fizz", "Fizz"), true); +}); + +test("matches a mention wrapped by Hangul on both sides", () => { + assert.equal(hasMention("그래서@Fizz한테", "Fizz"), true); +}); + +test("matches a mention immediately followed by a CJK ideograph", () => { + assert.equal(hasMention("@Fizz你好", "Fizz"), true); +}); + +test("matches a mention immediately followed by Kana", () => { + assert.equal(hasMention("@Fizzこんにちは", "Fizz"), true); +}); + +test("does not match when a Latin word follows the name", () => { + // `@Fizzbar` must NOT resolve to `@Fizz` — Latin letters are not a boundary. + assert.equal(hasMention("@Fizzbar", "Fizz"), false); +}); + +test("reports the correct offset for a Hangul-adjacent mention", () => { + const text = "블라 @Fizz이렇게"; + const offset = getMentionOffset(text, "Fizz"); + assert.notEqual(offset, null); + assert.equal(text.slice(offset, offset + 5), "@Fizz"); +}); diff --git a/desktop/src/features/messages/lib/hasMention.ts b/desktop/src/features/messages/lib/hasMention.ts index 5e5bafd8ad..cad4899b09 100644 --- a/desktop/src/features/messages/lib/hasMention.ts +++ b/desktop/src/features/messages/lib/hasMention.ts @@ -1,9 +1,4 @@ -/** - * Escape special regex characters in a string. - */ -function escapeRegExp(str: string): string { - return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); -} +import { CJK_BOUNDARY_RANGES, escapeRegExp } from "@/shared/lib/mentionPattern"; function maskRange( chars: string[], @@ -133,17 +128,20 @@ function maskMarkdownCode(text: string): string { * * Matches `@Name` preceded by start-of-string, whitespace, an opening * parenthesis (for team expansions), markdown - * bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`), or spoiler - * delimiters (`||`). This handles the case where a mention is pasted from the - * chat area and TipTap's Bold extension wraps it in bold marks (font-weight >= - * 500 -> bold), plus messages whose visible mention text is spoilered. + * bold/italic markers (`*`, `**`, `***`, `_`, `__`, `___`), spoiler + * delimiters (`||`), or a directly adjacent CJK/Hangul character. This handles + * the case where a mention is pasted from the chat area and TipTap's Bold + * extension wraps it in bold marks (font-weight >= 500 -> bold), messages whose + * visible mention text is spoilered, and mentions butted directly against + * Korean/Japanese/Chinese text with no separating space (e.g. `@Fizz이렇게`), + * which otherwise silently drop the p-tag and notification. * * Exported separately so it can be unit-tested without importing React. */ export function getMentionOffset(text: string, name: string): number | null { const escaped = escapeRegExp(name); const pattern = new RegExp( - `(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`, + `(^|\\s|\\(|[*_]{1,3}|\\|\\||[${CJK_BOUNDARY_RANGES}])(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_${CJK_BOUNDARY_RANGES}]|$)`, "i", ); const match = pattern.exec(maskMarkdownCode(text)); diff --git a/desktop/src/shared/lib/mentionPattern.test.mjs b/desktop/src/shared/lib/mentionPattern.test.mjs new file mode 100644 index 0000000000..eded451a67 --- /dev/null +++ b/desktop/src/shared/lib/mentionPattern.test.mjs @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildMentionPattern } from "./mentionPattern.ts"; + +function matchNames(text, names) { + const pattern = buildMentionPattern(names); + return [...text.matchAll(pattern)].map((m) => m[0]); +} + +test("matches a known name followed by whitespace", () => { + assert.deepEqual(matchNames("hi @Fizz there", ["Fizz"]), ["@Fizz"]); +}); + +test("matches a known name butted against Hangul", () => { + // Regression: `@Fizz이렇게` failed the boundary and was not highlighted. + assert.deepEqual(matchNames("@Fizz이렇게됨", ["Fizz"]), ["@Fizz"]); +}); + +test("matches a known name butted against a CJK ideograph", () => { + assert.deepEqual(matchNames("@Fizz你好", ["Fizz"]), ["@Fizz"]); +}); + +test("does not match a longer Latin word as a partial name", () => { + assert.deepEqual(matchNames("@Fizzbar", ["Fizz"]), []); +}); + +test("prefers the longest known name (longest-first)", () => { + assert.deepEqual(matchNames("@Fizz Bee한테", ["Fizz", "Fizz Bee"]), [ + "@Fizz Bee", + ]); +}); + +test("returns no matches when no known names are provided", () => { + assert.deepEqual(matchNames("@Fizz이렇게", []), []); +}); diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index 73d12a7a44..edb17b8dd6 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -7,6 +7,20 @@ export function escapeRegExp(str: string): string { const NEVER_MATCH = /(?!)/gi; +/** + * CJK / Hangul / Kana code-point ranges treated as a mention terminator. + * + * Display names are effectively Latin/ASCII, so a script transition from the + * name straight into a CJK character (e.g. `@Fizz이렇게` with no separating + * space) is an unambiguous word boundary. Without these ranges the boundary + * lookahead only accepts whitespace/punctuation, so a mention immediately + * followed by Korean/Japanese/Chinese text fails to match — the p-tag is never + * attached and the notification never fires. Covers Hangul Jamo, Kana, Hangul + * Compatibility Jamo, CJK Unified Ideographs, and Hangul Syllables. + */ +export const CJK_BOUNDARY_RANGES = + "\\u1100-\\u11FF\\u3040-\\u30FF\\u3130-\\u318F\\u4E00-\\u9FFF\\uAC00-\\uD7A3"; + /** * Build a regex that matches a given prefix followed by known multi-word names * (longest-first to avoid partial matches). When known names are provided, @@ -39,7 +53,7 @@ export function buildPrefixPattern( } const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); - const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; + const boundary = `(?=[\\s,;.!?:)\\]}${CJK_BOUNDARY_RANGES}]|$)`; return new RegExp(`${escapedPrefix}(?:${nameAlternatives})${boundary}`, "gi"); } From 0498344936583fb5fad0623b379e76b765811178 Mon Sep 17 00:00:00 2001 From: Taewoong Kim <46616734+Foreist@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:48:30 +0900 Subject: [PATCH 2/2] fix(desktop): clear stale composer caret ghost under backdrop-filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message composer shell wraps the Tiptap contenteditable in an element with backdrop-filter (backdrop-blur-md/xl). On WebKit — WKWebView, the macOS Tauri webview — a contenteditable nested under a backdrop-filter ancestor does not invalidate the previous caret rectangle on selection change. After pressing Enter, the caret from the prior line lingers as a "ghost" at its old position even after the real caret moves. Promote the editor onto its own compositing layer with transform: translateZ(0) so the stale caret rect is cleared on every selection change. An integer translateZ(0) keeps text rendering crisp (no subpixel blur). Co-Authored-By: Claude Opus 4.8 (1M context) --- desktop/src/shared/styles/globals/composer.css | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/desktop/src/shared/styles/globals/composer.css b/desktop/src/shared/styles/globals/composer.css index 912ed1c799..bde4d4e455 100644 --- a/desktop/src/shared/styles/globals/composer.css +++ b/desktop/src/shared/styles/globals/composer.css @@ -42,6 +42,13 @@ min-height: 1lh; font-size: var(--text-sm); line-height: var(--text-sm--line-height); + /* The composer shell above uses backdrop-filter (backdrop-blur). On WebKit + (WKWebView, the macOS Tauri webview) a contenteditable nested under a + backdrop-filter ancestor does not invalidate the previous caret rect, so + after Enter the old caret leaves a "ghost" at its prior position. Promoting + the editor onto its own compositing layer forces the stale caret rect to + clear on every selection change. Integer translateZ(0) keeps text crisp. */ + transform: translateZ(0); } .rich-text-composer .tiptap p {