mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
fix(desktop): resolve overlapping member mentions (#5225)
## Why Selecting or typing a member whose display name extends another member's name, such as `@Fast Fizz Codex`, could emit p-tags for both identities and wake the wrong agent. ## What - Resolve overlapping member-name matches by choosing the longest valid display name at each mention offset - Preserve separately typed short-name mentions at different offsets - Add regression coverage for selected team expansions and manually typed prefix collisions ## Risk Assessment Low to medium — this changes Desktop mention routing only. Exact mentions and distinct offsets remain supported; same-length ambiguous display names remain conservatively tagged because text alone cannot disambiguate them. Will resolve https://github.com/block/buzz/issues/2909 Generated with Goose Signed-off-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz> Signed-off-by: Atish Patel <atish@squareup.com> Co-authored-by: Hardworking Honey <c5c455215c2506cb8ba776518cec804af62d3a0526e32d496a22072e395042b9@buzz.block.builderlab.xyz>
This commit is contained in:
91
desktop/src/features/messages/lib/extractMentionPubkeys.ts
Normal file
91
desktop/src/features/messages/lib/extractMentionPubkeys.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import { getMentionOffsets } from "./hasMention";
|
||||
|
||||
export type MentionPubkeyCandidate = {
|
||||
displayName: string | null;
|
||||
isMember: boolean;
|
||||
pubkey?: string;
|
||||
};
|
||||
|
||||
type MentionMatch = {
|
||||
displayName: string;
|
||||
pubkey?: string;
|
||||
};
|
||||
|
||||
function normalizeDisplayName(name: string): string {
|
||||
return name.trim().toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns explicit selected mention pubkeys and manually typed channel-member
|
||||
* mentions. At each `@` offset, only the longest valid display name wins so a
|
||||
* member whose name prefixes another member is not spuriously tagged.
|
||||
*/
|
||||
export function extractMentionPubkeys({
|
||||
text,
|
||||
selectedMentions,
|
||||
selectedDisplayNames,
|
||||
memberCandidates,
|
||||
}: {
|
||||
text: string;
|
||||
selectedMentions: ReadonlyMap<string, string>;
|
||||
selectedDisplayNames?: Iterable<string>;
|
||||
memberCandidates: readonly MentionPubkeyCandidate[];
|
||||
}): string[] {
|
||||
const selectedNames = new Set(
|
||||
[...selectedMentions.keys(), ...(selectedDisplayNames ?? [])].map(
|
||||
normalizeDisplayName,
|
||||
),
|
||||
);
|
||||
const matchesByOffset = new Map<number, MentionMatch[]>();
|
||||
|
||||
const addMatches = (displayName: string, pubkey?: string) => {
|
||||
const trimmedName = displayName.trim();
|
||||
if (!trimmedName) return;
|
||||
|
||||
for (const offset of getMentionOffsets(text, trimmedName)) {
|
||||
const matches = matchesByOffset.get(offset) ?? [];
|
||||
matches.push({ displayName: trimmedName, pubkey });
|
||||
matchesByOffset.set(offset, matches);
|
||||
}
|
||||
};
|
||||
|
||||
for (const [displayName, pubkey] of selectedMentions) {
|
||||
addMatches(displayName, pubkey);
|
||||
}
|
||||
for (const displayName of selectedDisplayNames ?? []) {
|
||||
addMatches(displayName);
|
||||
}
|
||||
for (const candidate of memberCandidates) {
|
||||
if (
|
||||
candidate.pubkey &&
|
||||
candidate.isMember &&
|
||||
candidate.displayName &&
|
||||
!selectedNames.has(normalizeDisplayName(candidate.displayName))
|
||||
) {
|
||||
addMatches(candidate.displayName, candidate.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
const winningPubkeys = new Set<string>();
|
||||
for (const matches of matchesByOffset.values()) {
|
||||
const longestNameLength = Math.max(
|
||||
...matches.map((match) => match.displayName.length),
|
||||
);
|
||||
for (const match of matches) {
|
||||
if (match.pubkey && match.displayName.length === longestNameLength) {
|
||||
winningPubkeys.add(match.pubkey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pubkeys: string[] = [];
|
||||
for (const [, pubkey] of selectedMentions) {
|
||||
if (winningPubkeys.delete(pubkey)) pubkeys.push(pubkey);
|
||||
}
|
||||
for (const candidate of memberCandidates) {
|
||||
if (candidate.pubkey && winningPubkeys.delete(candidate.pubkey)) {
|
||||
pubkeys.push(candidate.pubkey);
|
||||
}
|
||||
}
|
||||
return pubkeys;
|
||||
}
|
||||
@@ -140,14 +140,24 @@ function maskMarkdownCode(text: string): string {
|
||||
*
|
||||
* Exported separately so it can be unit-tested without importing React.
|
||||
*/
|
||||
export function getMentionOffset(text: string, name: string): number | null {
|
||||
export function getMentionOffsets(text: string, name: string): number[] {
|
||||
const escaped = escapeRegExp(name);
|
||||
const pattern = new RegExp(
|
||||
`(^|\\s|\\(|[*_]{1,3}|\\|\\|)(@${escaped})(?=\\|\\||[\\s,;.!?:)\\]}*_]|$)`,
|
||||
"i",
|
||||
"gi",
|
||||
);
|
||||
const match = pattern.exec(maskMarkdownCode(text));
|
||||
return match ? match.index + match[1].length : null;
|
||||
const maskedText = maskMarkdownCode(text);
|
||||
const offsets: number[] = [];
|
||||
let match = pattern.exec(maskedText);
|
||||
while (match !== null) {
|
||||
offsets.push(match.index + match[1].length);
|
||||
match = pattern.exec(maskedText);
|
||||
}
|
||||
return offsets;
|
||||
}
|
||||
|
||||
export function getMentionOffset(text: string, name: string): number | null {
|
||||
return getMentionOffsets(text, name)[0] ?? null;
|
||||
}
|
||||
|
||||
export function hasMention(text: string, name: string): boolean {
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { getMentionOffset, hasMention } from "./hasMention.ts";
|
||||
import {
|
||||
getMentionOffset,
|
||||
getMentionOffsets,
|
||||
hasMention,
|
||||
} from "./hasMention.ts";
|
||||
import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
|
||||
|
||||
// ── Plain @mention ────────────────────────────────────────────────────
|
||||
|
||||
@@ -95,6 +100,111 @@ test("does not false-positive on partial name match", () => {
|
||||
assert.equal(hasMention("@Alice", "Al"), false);
|
||||
});
|
||||
|
||||
test("returns every mention offset", () => {
|
||||
const text = "@Fast Fizz Codex and @Fast Fizz";
|
||||
assert.deepEqual(getMentionOffsets(text, "Fast Fizz"), [0, 21]);
|
||||
});
|
||||
|
||||
test("selected longer mention excludes a prefix member at the same offset", () => {
|
||||
const pubkeys = extractMentionPubkeys({
|
||||
text: "Hive Codex(@Fast Fizz Codex)",
|
||||
selectedMentions: new Map([["Fast Fizz Codex", "codex-pubkey"]]),
|
||||
memberCandidates: [
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "fast-fizz-pubkey",
|
||||
displayName: "Fast Fizz",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "codex-pubkey",
|
||||
displayName: "Fast Fizz Codex",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(pubkeys, ["codex-pubkey"]);
|
||||
});
|
||||
|
||||
test("manual longer mention excludes its prefix member", () => {
|
||||
const pubkeys = extractMentionPubkeys({
|
||||
text: "@Fast Fizz Codex",
|
||||
selectedMentions: new Map(),
|
||||
memberCandidates: [
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "fast-fizz-pubkey",
|
||||
displayName: "Fast Fizz",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "codex-pubkey",
|
||||
displayName: "Fast Fizz Codex",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(pubkeys, ["codex-pubkey"]);
|
||||
});
|
||||
|
||||
test("manual prefix mentions choose the longest name at each offset", () => {
|
||||
const pubkeys = extractMentionPubkeys({
|
||||
text: "@Fast Fizz Codex, please pair with @Fast Fizz.",
|
||||
selectedMentions: new Map(),
|
||||
memberCandidates: [
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "fast-fizz-pubkey",
|
||||
displayName: "Fast Fizz",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "codex-pubkey",
|
||||
displayName: "Fast Fizz Codex",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(pubkeys, ["fast-fizz-pubkey", "codex-pubkey"]);
|
||||
});
|
||||
|
||||
test("keeps manually typed prefix member mentions at distinct offsets", () => {
|
||||
const pubkeys = extractMentionPubkeys({
|
||||
text: "@Fast Fizz Codex, please pair with @Fast Fizz.",
|
||||
selectedMentions: new Map([["Fast Fizz Codex", "codex-pubkey"]]),
|
||||
memberCandidates: [
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "fast-fizz-pubkey",
|
||||
displayName: "Fast Fizz",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
{
|
||||
kind: "identity",
|
||||
pubkey: "codex-pubkey",
|
||||
displayName: "Fast Fizz Codex",
|
||||
isMember: true,
|
||||
isAgent: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(pubkeys, ["codex-pubkey", "fast-fizz-pubkey"]);
|
||||
});
|
||||
|
||||
// ── Markdown code ─────────────────────────────────────────────────────
|
||||
|
||||
test("ignores mentions in inline code", () => {
|
||||
|
||||
@@ -40,6 +40,7 @@ import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { flushMentionDebounce } from "./flushMentionDebounce";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { extractMentionPubkeys } from "./extractMentionPubkeys";
|
||||
import { useDraftMentionRouting } from "./useDraftMentionRouting";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
import { mapMentionCandidateToSuggestion } from "./mentionSuggestionMapping";
|
||||
@@ -77,6 +78,7 @@ function appendUniqueName(current: string[], name: string): string[] {
|
||||
? current
|
||||
: [...current, name];
|
||||
}
|
||||
|
||||
export function useMentions(
|
||||
channelId: string | null,
|
||||
externalMembers?: ChannelMember[],
|
||||
@@ -791,43 +793,14 @@ export function useMentions(
|
||||
[],
|
||||
);
|
||||
|
||||
const extractMentionPubkeys = React.useCallback(
|
||||
(text: string): string[] => {
|
||||
const pubkeys: string[] = [];
|
||||
const selectedDisplayNames = new Set(
|
||||
[
|
||||
...mentionMapRef.current.keys(),
|
||||
...personaMentionMapRef.current.keys(),
|
||||
].map((name) => name.trim().toLowerCase()),
|
||||
);
|
||||
|
||||
for (const [displayName, pubkey] of mentionMapRef.current) {
|
||||
if (hasMention(text, displayName)) {
|
||||
pubkeys.push(pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
for (const candidate of mentionCandidates) {
|
||||
if (!candidate.pubkey) {
|
||||
continue;
|
||||
}
|
||||
if (!candidate.isMember) {
|
||||
continue;
|
||||
}
|
||||
if (pubkeys.includes(candidate.pubkey)) {
|
||||
continue;
|
||||
}
|
||||
const name = candidate.displayName;
|
||||
if (name && selectedDisplayNames.has(name.trim().toLowerCase())) {
|
||||
continue;
|
||||
}
|
||||
if (name && hasMention(text, name)) {
|
||||
pubkeys.push(candidate.pubkey);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(pubkeys)];
|
||||
},
|
||||
const extractMentionPubkeysForCurrentMentions = React.useCallback(
|
||||
(text: string): string[] =>
|
||||
extractMentionPubkeys({
|
||||
text,
|
||||
selectedMentions: mentionMapRef.current,
|
||||
selectedDisplayNames: personaMentionMapRef.current.keys(),
|
||||
memberCandidates: mentionCandidates,
|
||||
}),
|
||||
[mentionCandidates],
|
||||
);
|
||||
|
||||
@@ -972,7 +945,7 @@ export function useMentions(
|
||||
cancelMentionAutocomplete,
|
||||
clearMentions,
|
||||
extractMentionPersonas,
|
||||
extractMentionPubkeys,
|
||||
extractMentionPubkeys: extractMentionPubkeysForCurrentMentions,
|
||||
getDraftMentionRefs,
|
||||
getMentionDisplayName,
|
||||
handleMentionKeyDown,
|
||||
|
||||
Reference in New Issue
Block a user