mirror of
https://github.com/block/buzz.git
synced 2026-10-29 13:31:44 +00:00
fix(desktop): render channel chips independently of membership
Signed-off-by: Fizz <b46ef72d2feab665ebb73edf75cce1628e3e6368697f7081c51cf547d4b77a44@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
98
desktop/src/shared/lib/remarkChannelLinks.test.mjs
Normal file
98
desktop/src/shared/lib/remarkChannelLinks.test.mjs
Normal 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"]);
|
||||
}
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
40
desktop/tests/e2e/channel-reference-chips.spec.ts
Normal file
40
desktop/tests/e2e/channel-reference-chips.spec.ts
Normal 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");
|
||||
});
|
||||
Reference in New Issue
Block a user