From 81ca158639cb508df0ded8edee6d840c60069369 Mon Sep 17 00:00:00 2001 From: Fizz Date: Wed, 16 Sep 2026 17:35:20 +0200 Subject: [PATCH] fix(desktop): render channel chips independently of membership Signed-off-by: Fizz --- desktop/playwright.config.ts | 1 + desktop/src/shared/lib/mentionPattern.ts | 31 +++--- .../shared/lib/remarkChannelLinks.test.mjs | 98 +++++++++++++++++++ desktop/src/shared/lib/remarkChannelLinks.ts | 8 +- .../tests/e2e/channel-reference-chips.spec.ts | 40 ++++++++ 5 files changed, 160 insertions(+), 18 deletions(-) create mode 100644 desktop/src/shared/lib/remarkChannelLinks.test.mjs create mode 100644 desktop/tests/e2e/channel-reference-chips.spec.ts diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts index 76f27b6176..93c6a733f9 100644 --- a/desktop/playwright.config.ts +++ b/desktop/playwright.config.ts @@ -120,6 +120,7 @@ export default defineConfig({ "**/live-broadcast-reply-timeline.spec.ts", "**/markdown-parse-cache.spec.ts", "**/markdown-tables.spec.ts", + "**/channel-reference-chips.spec.ts", "**/overscroll-boundary.spec.ts", "**/terminal-wheel.spec.ts", "**/cold-switch-longtask.perf.ts", diff --git a/desktop/src/shared/lib/mentionPattern.ts b/desktop/src/shared/lib/mentionPattern.ts index d9dcf18589..eb233aa8f6 100644 --- a/desktop/src/shared/lib/mentionPattern.ts +++ b/desktop/src/shared/lib/mentionPattern.ts @@ -10,21 +10,18 @@ const NEVER_MATCH = /(?!)/gi; /** * 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, - * only those names are matched — no generic fallback. + * (longest-first to avoid partial matches). An optional generic token pattern + * stays available alongside known names, so channel chips do not depend on + * membership or a loaded directory. Generic matching uses mobile's channel + * boundaries to exclude URL fragments and trailing punctuation. * - * When no names are available: - * - If `options.fallbackToGeneric` is true, falls back to `prefix + \S+` so - * that patterns like `#channel` still render while channel names are loading - * asynchronously (used by remarkChannelLinks). - * - 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). + * Without a generic pattern, only known names match. In particular, arbitrary + * @words are never highlighted as mentions without matching p-tagged names. */ export function buildPrefixPattern( prefix: string, knownNames: string[], - options?: { fallbackToGeneric?: boolean }, + options?: { genericTokenPattern?: string }, ): RegExp { const sorted = [...new Set(knownNames)] .filter((name) => name.trim().length > 0) @@ -32,10 +29,7 @@ export function buildPrefixPattern( const escapedPrefix = escapeRegExp(prefix); - if (sorted.length === 0) { - if (options?.fallbackToGeneric) { - return new RegExp(`${escapedPrefix}\\S+`, "gi"); - } + if (sorted.length === 0 && !options?.genericTokenPattern) { return NEVER_MATCH; } @@ -45,6 +39,15 @@ export function buildPrefixPattern( ) .join("|"); const boundary = "(?=[\\s,;.!?:)\\]}]|$)"; + if (options?.genericTokenPattern) { + const alternatives = [nameAlternatives, options.genericTokenPattern] + .filter(Boolean) + .join("|"); + return new RegExp( + `(? nodesOfType(child, type)), + ]; +} + +function chips(tree) { + return nodesOfType(tree, "channel-link").map((node) => node.value); +} + +for (const names of [[], ["general"], ["general", "getting-started"]]) { + test(`channel chips survive loading and unrelated membership: ${JSON.stringify(names)}`, () => { + const tree = parse("See #general and #getting-started.", names); + assert.deepEqual(chips(tree), ["#general", "#getting-started"]); + const chip = nodesOfType(tree, "channel-link")[1]; + assert.equal(chip.data.hName, "channel-link"); + assert.equal(chip.data.channelName, "getting-started"); + assert.deepEqual(chip.data.hChildren, [ + { type: "text", value: "#getting-started" }, + ]); + assert.equal(tree.children[0].children.at(-1).value, "."); + }); + + test(`generic tokens exclude punctuation and URL fragments: ${JSON.stringify(names)}`, () => { + assert.deepEqual( + chips(parse("(#getting-started), #team_2! #123? #new-room;", names)), + ["#getting-started", "#team_2", "#123", "#new-room"], + ); + assert.deepEqual( + chips( + parse( + "https://example.com/a#getting-started word#getting-started /#general :#general -#general .#general", + names, + ), + ), + [], + ); + }); + + test(`code and authored links are not rewritten: ${JSON.stringify(names)}`, () => { + const tree = parse( + "`#getting-started` [#getting-started](https://example.com)\n\n```text\n#getting-started\n```", + names, + ); + assert.deepEqual(chips(tree), []); + assert.equal(nodesOfType(tree, "inlineCode")[0].value, "#getting-started"); + assert.equal(nodesOfType(tree, "code")[0].value, "#getting-started"); + assert.equal( + nodesOfType(tree, "link")[0].children[0].value, + "#getting-started", + ); + }); +} + +test("known names remain longest-first, case-insensitive, and regex-escaped", () => { + assert.deepEqual( + chips( + parse("#Design Team and #Design; #C++ plus #GETTING-STARTED", [ + "Design", + "Design Team", + "C++", + ]), + ), + ["#Design Team", "#Design", "#C++", "#GETTING-STARTED"], + ); +}); + +test("a known prefix does not truncate a longer unknown channel", () => { + assert.deepEqual(chips(parse("#general-help", ["general"])), [ + "#general-help", + ]); +}); + +test("channel fallback does not loosen user mentions", () => { + for (const mentionNames of [[], ["Alice Smith"]]) { + const tree = parse("@unknown @Alice Smith #getting-started", ["general"]); + remarkMentions({ mentionNames })(tree); + assert.deepEqual( + nodesOfType(tree, "mention").map((node) => node.value), + mentionNames.length ? ["@Alice Smith"] : [], + ); + assert.deepEqual(chips(tree), ["#getting-started"]); + } +}); diff --git a/desktop/src/shared/lib/remarkChannelLinks.ts b/desktop/src/shared/lib/remarkChannelLinks.ts index 008a685407..22d3fc7ef5 100644 --- a/desktop/src/shared/lib/remarkChannelLinks.ts +++ b/desktop/src/shared/lib/remarkChannelLinks.ts @@ -2,9 +2,9 @@ * Remark plugin that detects #channel-name patterns in text nodes and wraps them * in custom HAST `channel-link` elements for styled rendering via react-markdown. * - * Known channel names are matched longest-first to avoid partial matches. When - * no known names are provided, falls back to `#\S+` so that channel links still - * render while the channel list is loading asynchronously. + * Known channel names are matched longest-first to avoid partial matches. A + * generic channel token also matches independently of the reader's membership + * or directory loading state; resolution determines whether the chip opens. */ import { createRemarkPrefixPlugin } from "./createRemarkPrefixPlugin"; @@ -18,7 +18,7 @@ export default function remarkChannelLinks( options?: RemarkChannelLinksOptions, ) { const channelPattern = buildPrefixPattern("#", options?.channelNames ?? [], { - fallbackToGeneric: true, + genericTokenPattern: "[A-Za-z0-9_][A-Za-z0-9_-]*", }); return createRemarkPrefixPlugin(channelPattern, (matchText) => { diff --git a/desktop/tests/e2e/channel-reference-chips.spec.ts b/desktop/tests/e2e/channel-reference-chips.spec.ts new file mode 100644 index 0000000000..9fda92d369 --- /dev/null +++ b/desktop/tests/e2e/channel-reference-chips.spec.ts @@ -0,0 +1,40 @@ +import { expect, test } from "@playwright/test"; +import { installMockBridge } from "../helpers/bridge"; + +test("unknown channel references render inert chips beside known channels", async ({ + page, +}) => { + await installMockBridge(page); + await page.goto("/"); + await page.getByTestId("channel-general").click(); + await expect(page.getByTestId("chat-title")).toHaveText("general"); + await page.waitForFunction(() => + window.__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?.({ + channelName: "general", + }), + ); + const id = await page.evaluate(() => { + const message = window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__?.({ + channelName: "general", + content: + "Channel reference regression: #general and #getting-started. Keep `#code-room` and https://example.com/a#fragment unchanged.", + }); + if (!message) throw new Error("Mock message was not emitted"); + return message.id; + }); + const message = page + .getByTestId("message-timeline") + .locator(`[data-message-id="${id}"]`); + const chips = message.locator("[data-channel-link]"); + await expect(chips).toHaveCount(2); + await expect(chips.nth(0)).toHaveText("general"); + await expect(chips.nth(1)).toHaveText("getting-started"); + await expect(chips.nth(1)).not.toHaveAttribute("href"); + await expect(message.locator("code")).toHaveText("#code-room"); + await expect( + message.getByRole("link", { + name: "https://example.com/a#fragment", + exact: true, + }), + ).toHaveAttribute("href", "https://example.com/a#fragment"); +});