fix(desktop): render channel chips independently of membership

Signed-off-by: Fizz <b46ef72d2feab665ebb73edf75cce1628e3e6368697f7081c51cf547d4b77a44@buzz.block.builderlab.xyz>
This commit is contained in:
Fizz
2026-09-16 17:35:20 +02:00
parent 6d1f488d22
commit 81ca158639
5 changed files with 160 additions and 18 deletions

View File

@@ -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",

View File

@@ -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(
`(?<![\\w./:-])${escapedPrefix}(?:${alternatives})${boundary}`,
"gi",
);
}
return new RegExp(`${escapedPrefix}(?:${nameAlternatives})${boundary}`, "gi");
}

View File

@@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import test from "node:test";
import { fromMarkdown } from "mdast-util-from-markdown";
import remarkChannelLinks from "./remarkChannelLinks.ts";
import remarkMentions from "./remarkMentions.ts";
function parse(content, channelNames) {
const tree = fromMarkdown(content);
remarkChannelLinks({ channelNames })(tree);
return tree;
}
function nodesOfType(tree, type) {
return [
...(tree.type === type ? [tree] : []),
...(tree.children ?? []).flatMap((child) => 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"]);
}
});

View File

@@ -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) => {

View File

@@ -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");
});