From 9c2360174fe8a4258097a22e04c90d9ec0651364 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 16:53:39 +0530 Subject: [PATCH 1/5] refactor(desktop): let the prefix plugin return a match's leading boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `createRemarkPrefixPlugin` treats the whole match as the node's text, so a pattern that needs to assert what precedes it has nowhere to put that character. Lookbehind is not an option here: WebKit before Safari 16.4 fails to *parse* a lookbehind, which blanks the entire app rather than degrading one pattern (#5547). Add an opt-in `leadGroup` capture index. When set, that group's text is emitted back as plain text and the node is built from the rest of the match. Callers that don't pass it — the entity, message and channel deep-link patterns, which have capture groups of their own — are untouched. No behaviour change on its own. Signed-off-by: Taksh --- .../shared/lib/createRemarkPrefixPlugin.ts | 40 +++++++++++++++---- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts index 8fe1c91d24b..13177f42827 100644 --- a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts +++ b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts @@ -15,6 +15,14 @@ type NodeBuilderResult = Node | { node: Node; trailing?: string }; type NodeBuilder = (matchText: string) => NodeBuilderResult; +/** + * `leadGroup` names a capture group holding a leading boundary character that + * the pattern had to consume to assert one (WebKit before Safari 16.4 fails to + * *parse* lookbehind, blanking the whole app, so patterns capture instead). + * Its text is emitted back as plain text and is not part of the built node. + */ +type PrefixPluginOptions = { leadGroup?: number }; + /** * Create a remark plugin that walks the tree, finds regex matches in text * nodes, and replaces each match with a node produced by `buildNode`. @@ -22,17 +30,24 @@ type NodeBuilder = (matchText: string) => NodeBuilderResult; export function createRemarkPrefixPlugin( pattern: RegExp, buildNode: NodeBuilder, + options?: PrefixPluginOptions, ) { + const leadGroup = options?.leadGroup; return ( // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available tree: any, ) => { - walkChildren(tree, pattern, buildNode); + walkChildren(tree, pattern, buildNode, leadGroup); }; } // biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available -function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { +function walkChildren( + node: any, + pattern: RegExp, + buildNode: NodeBuilder, + leadGroup?: number, +) { if ( !node?.children || !Array.isArray(node.children) || @@ -45,7 +60,7 @@ function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { const child = node.children[i]; if (child.type === "text") { - const parts = splitByPattern(child.value, pattern, buildNode); + const parts = splitByPattern(child.value, pattern, buildNode, leadGroup); if ( parts.length > 1 || (parts.length === 1 && parts[0].type !== "text") @@ -53,7 +68,7 @@ function walkChildren(node: any, pattern: RegExp, buildNode: NodeBuilder) { node.children.splice(i, 1, ...parts); } } else { - walkChildren(child, pattern, buildNode); + walkChildren(child, pattern, buildNode, leadGroup); } } } @@ -65,7 +80,12 @@ function shouldSkipNode(node: any): boolean { ); } -function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { +function splitByPattern( + text: string, + pattern: RegExp, + buildNode: NodeBuilder, + leadGroup?: number, +) { // Reset lastIndex — the pattern is reused across text nodes with the `g` flag pattern.lastIndex = 0; // biome-ignore lint/suspicious/noExplicitAny: building mdast-compatible nodes @@ -79,11 +99,15 @@ function splitByPattern(text: string, pattern: RegExp, buildNode: NodeBuilder) { break; } - if (match.index > lastIndex) { - parts.push({ type: "text", value: text.slice(lastIndex, match.index) }); + const lead = leadGroup === undefined ? "" : (match[leadGroup] ?? ""); + const matchStart = match.index + lead.length; + if (matchStart > lastIndex) { + parts.push({ type: "text", value: text.slice(lastIndex, matchStart) }); } - const result = normalizeBuildNodeResult(buildNode(match[0])); + const result = normalizeBuildNodeResult( + buildNode(match[0].slice(lead.length)), + ); parts.push(result.node); if (result.trailing) { parts.push({ type: "text", value: result.trailing }); From b1386dcf62e4144dee89a43b4b4caee1dae1eb65 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sat, 15 Aug 2026 16:59:32 +0530 Subject: [PATCH 2/5] fix(desktop): don't render a mention inside a word MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The composer highlights `@name` only at the start of the input or after whitespace or `(`. The rendered message applied no such rule: any occurrence of a p-tagged name after `@` became a mention chip, wherever it sat. So a message that mentions @alice and also gives out an address — @alice can you mail me at bob@alice.dev? renders `@alice` inside the address as a second mention of her. The two halves of the same feature disagreed about what a mention is, and the rendered half was the wrong one: nothing was ever tagged for that occurrence. Require the same leading boundary in the rendered path, using the capture group added in the previous commit rather than a lookbehind. The channel pattern gets the same treatment for the same reason — it shares the builder, and `issue-42#general` is no more a channel link than the address is a mention. Signed-off-by: Taksh --- .../src/shared/lib/mentionPattern.test.mjs | 80 +++++++++++++++++++ desktop/src/shared/lib/mentionPattern.ts | 27 ++++++- desktop/src/shared/lib/remarkChannelLinks.ts | 30 ++++--- desktop/src/shared/lib/remarkMentions.ts | 22 ++--- 4 files changed, 135 insertions(+), 24 deletions(-) create mode 100644 desktop/src/shared/lib/mentionPattern.test.mjs diff --git a/desktop/src/shared/lib/mentionPattern.test.mjs b/desktop/src/shared/lib/mentionPattern.test.mjs new file mode 100644 index 00000000000..6a14e394cef --- /dev/null +++ b/desktop/src/shared/lib/mentionPattern.test.mjs @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + buildMentionPattern, + buildPrefixPattern, + PREFIX_LEAD_GROUP, +} from "./mentionPattern.ts"; + +/** All matches, with the leading boundary stripped the way the plugin does. */ +function matches(pattern, text) { + pattern.lastIndex = 0; + const found = []; + let match = pattern.exec(text); + while (match) { + const lead = match[PREFIX_LEAD_GROUP] ?? ""; + found.push({ + text: match[0].slice(lead.length), + index: match.index + lead.length, + }); + match = pattern.exec(text); + } + return found; +} + +test("a mention is matched at the start of the text", () => { + const pattern = buildMentionPattern(["alice"]); + assert.deepEqual(matches(pattern, "@alice hi"), [ + { text: "@alice", index: 0 }, + ]); +}); + +test("a mention is matched after whitespace and after an opening paren", () => { + const pattern = buildMentionPattern(["alice"]); + assert.deepEqual(matches(pattern, "hi @alice"), [ + { text: "@alice", index: 3 }, + ]); + assert.deepEqual(matches(pattern, "hi (@alice)"), [ + { text: "@alice", index: 4 }, + ]); +}); + +test("an email address is not a mention of its domain", () => { + // "@alice" here is part of bob@alice.dev — the composer's own highlighter + // draws the boundary in the same place, so the rendered message should too. + const pattern = buildMentionPattern(["alice"]); + assert.deepEqual(matches(pattern, "mail me at bob@alice.dev"), []); +}); + +test("the boundary does not eat the space between two adjacent mentions", () => { + const pattern = buildMentionPattern(["alice", "bob"]); + assert.deepEqual(matches(pattern, "@alice @bob"), [ + { text: "@alice", index: 0 }, + { text: "@bob", index: 7 }, + ]); +}); + +test("longest known name still wins over a shorter prefix of it", () => { + const pattern = buildMentionPattern(["ali", "alice"]); + assert.deepEqual(matches(pattern, "hi @alice"), [ + { text: "@alice", index: 3 }, + ]); +}); + +test("unknown names are not matched", () => { + const pattern = buildMentionPattern(["alice"]); + assert.deepEqual(matches(pattern, "hi @carol"), []); +}); + +test("with no known names the mention pattern never matches", () => { + assert.deepEqual(matches(buildMentionPattern([]), "hi @alice"), []); +}); + +test("the generic channel fallback also requires a leading boundary", () => { + const pattern = buildPrefixPattern("#", [], { fallbackToGeneric: true }); + assert.deepEqual(matches(pattern, "see #general"), [ + { text: "#general", index: 4 }, + ]); + assert.deepEqual(matches(pattern, "issue-42#general"), []); +}); diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index 73d12a7a44b..fb4f5e2a4a0 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -7,6 +7,26 @@ export function escapeRegExp(str: string): string { const NEVER_MATCH = /(?!)/gi; +/** + * Capture index of the leading boundary character in a pattern built by + * {@link buildPrefixPattern} — pass it to `createRemarkPrefixPlugin` as + * `leadGroup` so the character is re-emitted as text rather than swallowed + * into the mention. + */ +export const PREFIX_LEAD_GROUP = 1; + +/** + * A prefix only opens a mention or channel link at the start of the text or + * after whitespace or an opening paren — `bob@alice.dev` is an address, not a + * mention of `@alice`, and the composer's own highlighter already draws the + * line in the same place. + * + * The boundary is a capture group rather than a lookbehind on purpose: WebKit + * before Safari 16.4 fails to parse lookbehind and blanks the whole app + * (#5547). + */ +const LEADING_BOUNDARY = "(^|[\\s(])"; + /** * 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, @@ -33,14 +53,17 @@ export function buildPrefixPattern( if (sorted.length === 0) { if (options?.fallbackToGeneric) { - return new RegExp(`${escapedPrefix}\\S+`, "gi"); + return new RegExp(`${LEADING_BOUNDARY}${escapedPrefix}\\S+`, "gi"); } return NEVER_MATCH; } const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; - return new RegExp(`${escapedPrefix}(?:${nameAlternatives})${boundary}`, "gi"); + return new RegExp( + `${LEADING_BOUNDARY}${escapedPrefix}(?:${nameAlternatives})${boundary}`, + "gi", + ); } /** diff --git a/desktop/src/shared/lib/remarkChannelLinks.ts b/desktop/src/shared/lib/remarkChannelLinks.ts index 008a6854071..52af95ac344 100644 --- a/desktop/src/shared/lib/remarkChannelLinks.ts +++ b/desktop/src/shared/lib/remarkChannelLinks.ts @@ -8,7 +8,7 @@ */ import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin"; -import { buildPrefixPattern } from "./mentionPattern"; +import { buildPrefixPattern, PREFIX_LEAD_GROUP } from "./mentionPattern"; type RemarkChannelLinksOptions = { channelNames?: string[]; @@ -21,16 +21,20 @@ export default function remarkChannelLinks( fallbackToGeneric: true, }); - return createRemarkPrefixPlugin(channelPattern, (matchText) => { - const channelName = matchText.slice(1); - return { - type: "channel-link", - value: matchText, - data: { - hName: "channel-link", - hChildren: [{ type: "text", value: matchText }], - channelName, - }, - }; - }); + return createRemarkPrefixPlugin( + channelPattern, + (matchText) => { + const channelName = matchText.slice(1); + return { + type: "channel-link", + value: matchText, + data: { + hName: "channel-link", + hChildren: [{ type: "text", value: matchText }], + channelName, + }, + }; + }, + { leadGroup: PREFIX_LEAD_GROUP }, + ); } diff --git a/desktop/src/shared/lib/remarkMentions.ts b/desktop/src/shared/lib/remarkMentions.ts index 7413df6f35c..ec8773af567 100644 --- a/desktop/src/shared/lib/remarkMentions.ts +++ b/desktop/src/shared/lib/remarkMentions.ts @@ -8,7 +8,7 @@ */ import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin"; -import { buildMentionPattern } from "./mentionPattern"; +import { buildMentionPattern, PREFIX_LEAD_GROUP } from "./mentionPattern"; type RemarkMentionsOptions = { mentionNames?: string[]; @@ -17,12 +17,16 @@ type RemarkMentionsOptions = { export default function remarkMentions(options?: RemarkMentionsOptions) { const mentionPattern = buildMentionPattern(options?.mentionNames ?? []); - return createRemarkPrefixPlugin(mentionPattern, (matchText) => ({ - type: "mention", - value: matchText, - data: { - hName: "mention", - hChildren: [{ type: "text", value: matchText }], - }, - })); + return createRemarkPrefixPlugin( + mentionPattern, + (matchText) => ({ + type: "mention", + value: matchText, + data: { + hName: "mention", + hChildren: [{ type: "text", value: matchText }], + }, + }), + { leadGroup: PREFIX_LEAD_GROUP }, + ); } From b0d87581327310c71a67bb50394314a99d667374 Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 16:25:27 +0530 Subject: [PATCH 3/5] fix(desktop): don't let an opening paren open a channel link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: the leading boundary was shared between both prefixes and included `(`, so `(#general)` became a channel link in a rendered message — more permissive than the composer's highlighter, which this change set is aligning the rendered path to. The composer draws the line in two different places on purpose: mentions (?:^|(?<=[\s(]))@ — team expansions render as `Team (@ana @bo)` channels (?:^|(?<=\s))# — channels have no such form `buildPrefixPattern` now takes `allowOpeningParen`, and only `buildMentionPattern` opts in. It defaults to off so the stricter rule is what a new caller gets. Without this, `(#general)` shows no chip while typing and turns into a link once sent. - focused `mentionPattern.test.mjs` — 9 passed Signed-off-by: Taksh --- .../src/shared/lib/mentionPattern.test.mjs | 17 +++++++++++ desktop/src/shared/lib/mentionPattern.ts | 30 ++++++++++++++----- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/desktop/src/shared/lib/mentionPattern.test.mjs b/desktop/src/shared/lib/mentionPattern.test.mjs index 6a14e394cef..dcc608bf34e 100644 --- a/desktop/src/shared/lib/mentionPattern.test.mjs +++ b/desktop/src/shared/lib/mentionPattern.test.mjs @@ -78,3 +78,20 @@ test("the generic channel fallback also requires a leading boundary", () => { ]); assert.deepEqual(matches(pattern, "issue-42#general"), []); }); + +test("an opening paren opens a mention but not a channel", () => { + // Exactly the split the composer's highlighter draws: `(?<=[\s(])@` versus + // `(?<=\s)#`. Team expansions render as `Team (@ana @bo)`; channels have no + // such form, so `(#general)` is not a channel link. + assert.deepEqual(matches(buildMentionPattern(["alice"]), "(@alice)"), [ + { text: "@alice", index: 1 }, + ]); + assert.deepEqual( + matches(buildPrefixPattern("#", ["general"]), "(#general)"), + [], + ); + assert.deepEqual( + matches(buildPrefixPattern("#", [], { fallbackToGeneric: true }), "(#gen)"), + [], + ); +}); diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index fb4f5e2a4a0..40aae79244d 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -17,15 +17,22 @@ export const PREFIX_LEAD_GROUP = 1; /** * A prefix only opens a mention or channel link at the start of the text or - * after whitespace or an opening paren — `bob@alice.dev` is an address, not a - * mention of `@alice`, and the composer's own highlighter already draws the - * line in the same place. + * after whitespace — `bob@alice.dev` is an address, not a mention of `@alice`. * * The boundary is a capture group rather than a lookbehind on purpose: WebKit * before Safari 16.4 fails to parse lookbehind and blanks the whole app * (#5547). */ -const LEADING_BOUNDARY = "(^|[\\s(])"; +const LEADING_BOUNDARY = "(^|\\s)"; + +/** + * Mentions additionally open after `(`, because team expansions render as + * `Team (@ana @bo)`. Channels deliberately do not — these two boundaries are + * exactly the ones the composer's highlighter applies to `@` and `#` + * respectively, and the rendered message has to agree with the composer or a + * `(#general)` that shows no chip while typing turns into a link once sent. + */ +const LEADING_BOUNDARY_WITH_PAREN = "(^|[\\s(])"; /** * Build a regex that matches a given prefix followed by known multi-word names @@ -39,21 +46,28 @@ const LEADING_BOUNDARY = "(^|[\\s(])"; * - Otherwise returns a never-matching regex, preventing arbitrary `@word` * patterns from being highlighted as valid mentions when no p-tags are * present (used by remarkMentions / buildMentionPattern). + * + * `options.allowOpeningParen` widens the leading boundary to include `(`; see + * {@link LEADING_BOUNDARY_WITH_PAREN}. It is off by default so a new caller + * gets the stricter rule. */ export function buildPrefixPattern( prefix: string, knownNames: string[], - options?: { fallbackToGeneric?: boolean }, + options?: { fallbackToGeneric?: boolean; allowOpeningParen?: boolean }, ): RegExp { const sorted = [...new Set(knownNames)] .filter((name) => name.trim().length > 0) .sort((a, b) => b.length - a.length); const escapedPrefix = escapeRegExp(prefix); + const lead = options?.allowOpeningParen + ? LEADING_BOUNDARY_WITH_PAREN + : LEADING_BOUNDARY; if (sorted.length === 0) { if (options?.fallbackToGeneric) { - return new RegExp(`${LEADING_BOUNDARY}${escapedPrefix}\\S+`, "gi"); + return new RegExp(`${lead}${escapedPrefix}\\S+`, "gi"); } return NEVER_MATCH; } @@ -61,7 +75,7 @@ export function buildPrefixPattern( const nameAlternatives = sorted.map((name) => escapeRegExp(name)).join("|"); const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; return new RegExp( - `${LEADING_BOUNDARY}${escapedPrefix}(?:${nameAlternatives})${boundary}`, + `${lead}${escapedPrefix}(?:${nameAlternatives})${boundary}`, "gi", ); } @@ -73,5 +87,5 @@ export function buildPrefixPattern( * they correspond to an actual p-tagged member. */ export function buildMentionPattern(mentionNames: string[]): RegExp { - return buildPrefixPattern("@", mentionNames); + return buildPrefixPattern("@", mentionNames, { allowOpeningParen: true }); } From 763b790c8090b7de462255499b3c2dea9104536d Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 16:26:12 +0530 Subject: [PATCH 4/5] style(desktop): retype the prefix-plugin walkers instead of suppressing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: reformatting `walkChildren` onto multiple lines detached its `biome-ignore` from the `node: any` parameter it was covering, which cost two diagnostics — a `noExplicitAny` warning on the parameter and an `suppressions/unused` warning on the orphaned comment. `pnpm check` reported 4 warnings on this branch against 2 on main. Both internal walkers take the structural `Node` type the file already defines for built nodes, so no suppression is needed at all. - `pnpm check` — back to 2 warnings and 2 infos, all pre-existing - `pnpm typecheck` — pass Signed-off-by: Taksh --- desktop/src/shared/lib/createRemarkPrefixPlugin.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts index 13177f42827..fcc246d186b 100644 --- a/desktop/src/shared/lib/createRemarkPrefixPlugin.ts +++ b/desktop/src/shared/lib/createRemarkPrefixPlugin.ts @@ -41,9 +41,8 @@ export function createRemarkPrefixPlugin( }; } -// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available function walkChildren( - node: any, + node: Node, pattern: RegExp, buildNode: NodeBuilder, leadGroup?: number, @@ -73,8 +72,7 @@ function walkChildren( } } -// biome-ignore lint/suspicious/noExplicitAny: remark tree types are not available -function shouldSkipNode(node: any): boolean { +function shouldSkipNode(node: Node): boolean { return ( node.type === "link" || node.type === "code" || node.type === "inlineCode" ); From e6bd68268df52e52cd78ebf0db298816f085ccbc Mon Sep 17 00:00:00 2001 From: Taksh Date: Sun, 16 Aug 2026 16:26:27 +0530 Subject: [PATCH 5/5] test(desktop): exercise the prefix plugin, not a copy of its logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review: the boundary-preservation test asserted through a helper that strips capture group 1 by hand — a re-implementation of what the plugin does, not the plugin. `createRemarkPrefixPlugin` could have swallowed the boundary character instead of emitting it back as text and the test would still have passed. These run both real plugins over a real mdast tree and assert on node types and values, so a dropped space fails the assertion. The pattern-level tests stay where they are — they are about the regex — but the tree behaviour now has its own coverage, including the composer-parity case that `(#general)` stays plain text. - focused `createRemarkPrefixPlugin.test.mjs` — 9 passed Signed-off-by: Taksh --- .../lib/createRemarkPrefixPlugin.test.mjs | 103 ++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 desktop/src/shared/lib/createRemarkPrefixPlugin.test.mjs diff --git a/desktop/src/shared/lib/createRemarkPrefixPlugin.test.mjs b/desktop/src/shared/lib/createRemarkPrefixPlugin.test.mjs new file mode 100644 index 00000000000..6712fe70cdf --- /dev/null +++ b/desktop/src/shared/lib/createRemarkPrefixPlugin.test.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import remarkChannelLinks from "./remarkChannelLinks.ts"; +import remarkMentions from "./remarkMentions.ts"; + +/** + * These run the real plugins over a real mdast tree. The pattern-level tests in + * `mentionPattern.test.mjs` strip the leading-boundary capture group by hand, + * which cannot catch a factory that swallows the boundary character instead of + * emitting it back as text — that only shows up in the tree. + */ + +function paragraph(...children) { + return { type: "root", children: [{ type: "paragraph", children }] }; +} +function text(value) { + return { type: "text", value }; +} +function kids(tree) { + return tree.children[0].children; +} +/** `[type, value]` for each child, so a swallowed space fails the assertion. */ +function shape(tree) { + return kids(tree).map((node) => [node.type, node.value]); +} + +function runMentions(value, names = ["alice", "bob"]) { + const tree = paragraph(text(value)); + remarkMentions({ mentionNames: names })(tree); + return tree; +} + +function runChannels(value, names = ["general"]) { + const tree = paragraph(text(value)); + remarkChannelLinks({ channelNames: names })(tree); + return tree; +} + +test("the boundary character survives as text before the mention", () => { + assert.deepEqual(shape(runMentions("hi @alice")), [ + ["text", "hi "], + ["mention", "@alice"], + ]); +}); + +test("adjacent mentions keep the space between them", () => { + assert.deepEqual(shape(runMentions("@alice @bob")), [ + ["mention", "@alice"], + ["text", " "], + ["mention", "@bob"], + ]); +}); + +test("a mention at the start of the text produces no empty leading node", () => { + assert.deepEqual(shape(runMentions("@alice hi")), [ + ["mention", "@alice"], + ["text", " hi"], + ]); +}); + +test("an opening paren before a mention stays text", () => { + // Team expansions render as `Team (@ana @bo)`, so `(` opens a mention. + assert.deepEqual(shape(runMentions("Team (@alice)")), [ + ["text", "Team ("], + ["mention", "@alice"], + ["text", ")"], + ]); +}); + +test("an email address is left entirely alone", () => { + assert.deepEqual(shape(runMentions("mail bob@alice.dev now")), [ + ["text", "mail bob@alice.dev now"], + ]); +}); + +test("a channel link keeps its preceding text", () => { + assert.deepEqual(shape(runChannels("see #general")), [ + ["text", "see "], + ["channel-link", "#general"], + ]); +}); + +test("an opening paren does not open a channel link", () => { + // The composer's channel highlighter accepts only start-of-text or + // whitespace; rendered messages must not be more permissive, or `(#general)` + // shows no chip while typing and turns into a link once sent. + assert.deepEqual(shape(runChannels("ask in (#general)")), [ + ["text", "ask in (#general)"], + ]); +}); + +test("the generic channel fallback also refuses a mid-word prefix", () => { + assert.deepEqual(shape(runChannels("issue-42#general", [])), [ + ["text", "issue-42#general"], + ]); +}); + +test("inline code is left untouched", () => { + const tree = paragraph({ type: "inlineCode", value: "@alice" }); + remarkMentions({ mentionNames: ["alice"] })(tree); + assert.deepEqual(shape(tree), [["inlineCode", "@alice"]]); +});