mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Prioritize channel members in mention autocomplete (#1431)
Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
@@ -389,9 +389,15 @@ export function ChannelScreen({
|
||||
messageProfilesQuery.data?.profiles,
|
||||
currentProfile,
|
||||
) ?? {};
|
||||
return mergeAgentNamesIntoProfiles(base, managedAgents, relayAgents);
|
||||
return mergeAgentNamesIntoProfiles(
|
||||
base,
|
||||
managedAgents,
|
||||
relayAgents,
|
||||
currentPubkey,
|
||||
);
|
||||
}, [
|
||||
currentProfile,
|
||||
currentPubkey,
|
||||
managedAgents,
|
||||
messageProfilesQuery.data?.profiles,
|
||||
relayAgents,
|
||||
|
||||
@@ -97,6 +97,7 @@ export function mergeAgentNamesIntoProfiles(
|
||||
profiles: UserProfileLookup,
|
||||
managedAgents: ManagedAgent[],
|
||||
relayAgents: RelayAgent[],
|
||||
currentPubkey?: string | null,
|
||||
): UserProfileLookup {
|
||||
const merged = { ...profiles };
|
||||
for (const agent of relayAgents) {
|
||||
@@ -116,6 +117,7 @@ export function mergeAgentNamesIntoProfiles(
|
||||
displayName: merged[key]?.displayName || agent.name,
|
||||
avatarUrl: merged[key]?.avatarUrl ?? agent.avatarUrl,
|
||||
nip05Handle: merged[key]?.nip05Handle ?? null,
|
||||
ownerPubkey: merged[key]?.ownerPubkey ?? currentPubkey ?? null,
|
||||
isAgent: true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { rankMentionCandidates } from "./mentionRanking.ts";
|
||||
|
||||
const CHANNEL_BRAIN_PUBKEY = "1".repeat(64);
|
||||
const OTHER_BRAIN_PUBKEY = "2".repeat(64);
|
||||
|
||||
function candidate(overrides = {}) {
|
||||
return {
|
||||
kind: "identity",
|
||||
displayName: "Brain",
|
||||
isAgent: false,
|
||||
isMember: false,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function rankedPubkeys(
|
||||
candidates,
|
||||
query = "brain",
|
||||
activePersonaIds = new Set(),
|
||||
) {
|
||||
return rankMentionCandidates(candidates, query, activePersonaIds).map(
|
||||
(item) => item.candidate.pubkey ?? `persona:${item.candidate.personaId}`,
|
||||
);
|
||||
}
|
||||
|
||||
test("rankMentionCandidates: channel members outrank runnable personas, people, and other agents", () => {
|
||||
const persona = candidate({
|
||||
kind: "persona",
|
||||
personaId: "brain-persona",
|
||||
pubkey: undefined,
|
||||
});
|
||||
const remoteAgent = candidate({
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
const person = candidate({
|
||||
pubkey: "6".repeat(64),
|
||||
});
|
||||
const channelMember = candidate({
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rankedPubkeys([persona, remoteAgent, person, channelMember]),
|
||||
[
|
||||
CHANNEL_BRAIN_PUBKEY,
|
||||
"persona:brain-persona",
|
||||
"6".repeat(64),
|
||||
OTHER_BRAIN_PUBKEY,
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("rankMentionCandidates: exact and prefix quality sort within the channel-member group", () => {
|
||||
const wordPrefixMember = candidate({
|
||||
displayName: "The Brain",
|
||||
isMember: true,
|
||||
pubkey: "3".repeat(64),
|
||||
});
|
||||
const exactMember = candidate({
|
||||
displayName: "Brain",
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
const prefixMember = candidate({
|
||||
displayName: "Brainiac",
|
||||
isMember: true,
|
||||
pubkey: "4".repeat(64),
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rankedPubkeys([wordPrefixMember, exactMember, prefixMember]),
|
||||
[CHANNEL_BRAIN_PUBKEY, "4".repeat(64), "3".repeat(64)],
|
||||
);
|
||||
});
|
||||
|
||||
test("rankMentionCandidates: matching secondary labels participate in ranking", () => {
|
||||
const memberByHandle = candidate({
|
||||
displayName: "Acme Bot",
|
||||
secondaryLabel: "brain@example.com",
|
||||
isMember: true,
|
||||
pubkey: CHANNEL_BRAIN_PUBKEY,
|
||||
});
|
||||
const nonMemberName = candidate({
|
||||
displayName: "Brain",
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.deepEqual(rankedPubkeys([nonMemberName, memberByHandle]), [
|
||||
CHANNEL_BRAIN_PUBKEY,
|
||||
OTHER_BRAIN_PUBKEY,
|
||||
]);
|
||||
});
|
||||
|
||||
test("rankMentionCandidates: active persona-backed non-members outrank other non-member agents", () => {
|
||||
const activePersonaAgent = candidate({
|
||||
displayName: "Brain",
|
||||
isAgent: true,
|
||||
personaId: "brain-persona",
|
||||
pubkey: "5".repeat(64),
|
||||
});
|
||||
const remoteAgent = candidate({
|
||||
displayName: "Brain",
|
||||
isAgent: true,
|
||||
pubkey: OTHER_BRAIN_PUBKEY,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rankedPubkeys(
|
||||
[remoteAgent, activePersonaAgent],
|
||||
"brain",
|
||||
new Set(["brain-persona"]),
|
||||
),
|
||||
["5".repeat(64), OTHER_BRAIN_PUBKEY],
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export type MentionCandidateForRanking = {
|
||||
displayName: string | null;
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
kind: "identity" | "persona";
|
||||
personaId?: string | null;
|
||||
personaName?: string | null;
|
||||
pubkey?: string;
|
||||
secondaryLabel?: string | null;
|
||||
};
|
||||
|
||||
export type RankedMentionCandidate<T extends MentionCandidateForRanking> = {
|
||||
candidate: T;
|
||||
groupRank: number;
|
||||
label: string;
|
||||
order: number;
|
||||
score: number;
|
||||
};
|
||||
|
||||
function getMentionCandidateGroupRank(
|
||||
candidate: MentionCandidateForRanking,
|
||||
activePersonaIds: ReadonlySet<string>,
|
||||
) {
|
||||
if (candidate.isMember) return 0;
|
||||
|
||||
const isRunnablePersona =
|
||||
candidate.kind === "persona" ||
|
||||
(candidate.personaId ? activePersonaIds.has(candidate.personaId) : false);
|
||||
if (isRunnablePersona) return 1;
|
||||
|
||||
if (!candidate.isAgent) return 2;
|
||||
|
||||
return 3;
|
||||
}
|
||||
|
||||
function scoreMentionCandidateLabel(
|
||||
label: string,
|
||||
lowerQuery: string,
|
||||
): number | null {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower === lowerQuery) return 0;
|
||||
if (lower.startsWith(lowerQuery)) return 1;
|
||||
|
||||
const words = lower.split(/[\s\-_]+/).filter(Boolean);
|
||||
if (words.some((word) => word === lowerQuery)) return 2;
|
||||
if (words.some((word) => word.startsWith(lowerQuery))) return 3;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function rankMentionCandidates<T extends MentionCandidateForRanking>(
|
||||
candidates: readonly T[],
|
||||
query: string,
|
||||
activePersonaIds: ReadonlySet<string> = new Set(),
|
||||
): RankedMentionCandidate<T>[] {
|
||||
const lowerQuery = query.toLowerCase();
|
||||
|
||||
return candidates
|
||||
.map((candidate, order) => {
|
||||
const pubkeyLower = candidate.pubkey
|
||||
? normalizePubkey(candidate.pubkey)
|
||||
: "";
|
||||
const label =
|
||||
candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
|
||||
const groupRank = getMentionCandidateGroupRank(
|
||||
candidate,
|
||||
activePersonaIds,
|
||||
);
|
||||
|
||||
const labelScores = [
|
||||
candidate.displayName,
|
||||
candidate.personaName,
|
||||
candidate.secondaryLabel,
|
||||
]
|
||||
.map((value) =>
|
||||
value ? scoreMentionCandidateLabel(value, lowerQuery) : null,
|
||||
)
|
||||
.filter((score): score is number => score !== null);
|
||||
const labelScore =
|
||||
labelScores.length > 0 ? Math.min(...labelScores) : null;
|
||||
|
||||
const pubkeyScore = candidate.pubkey
|
||||
? pubkeyLower.startsWith(lowerQuery)
|
||||
? 4
|
||||
: pubkeyLower.includes(lowerQuery)
|
||||
? 5
|
||||
: null
|
||||
: null;
|
||||
const score = labelScore !== null ? labelScore : pubkeyScore;
|
||||
|
||||
return { candidate, groupRank, label, order, score };
|
||||
})
|
||||
.filter((item): item is RankedMentionCandidate<T> => item.score !== null)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.groupRank - b.groupRank || a.score - b.score || a.order - b.order,
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import { detectPrefixQuery } from "@/shared/lib/detectPrefixQuery";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
import { hasMention } from "./hasMention";
|
||||
import { rankMentionCandidates } from "./mentionRanking";
|
||||
|
||||
const MENTION_DEBOUNCE_MS = 120;
|
||||
const MENTION_SUGGESTION_LIMIT = 50;
|
||||
@@ -103,13 +104,22 @@ function formatSearchUserSecondaryLabel(user: UserSearchResult) {
|
||||
|
||||
function formatOwnerLabel(
|
||||
ownerPubkey: string | null | undefined,
|
||||
currentPubkey: string | null | undefined,
|
||||
ownerProfiles?: UserProfileLookup,
|
||||
) {
|
||||
if (!ownerPubkey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const owner = ownerProfiles?.[normalizePubkey(ownerPubkey)];
|
||||
const normalizedOwnerPubkey = normalizePubkey(ownerPubkey);
|
||||
if (
|
||||
currentPubkey &&
|
||||
normalizedOwnerPubkey === normalizePubkey(currentPubkey)
|
||||
) {
|
||||
return "you";
|
||||
}
|
||||
|
||||
const owner = ownerProfiles?.[normalizedOwnerPubkey];
|
||||
return (
|
||||
owner?.displayName?.trim() ||
|
||||
owner?.nip05Handle?.trim() ||
|
||||
@@ -309,7 +319,13 @@ export function useMentions(
|
||||
role: current.role ?? candidate.role ?? null,
|
||||
secondaryLabel:
|
||||
current.secondaryLabel ?? candidate.secondaryLabel ?? null,
|
||||
ownerPubkey: current.ownerPubkey ?? candidate.ownerPubkey ?? null,
|
||||
ownerPubkey:
|
||||
current.ownerPubkey ??
|
||||
candidate.ownerPubkey ??
|
||||
(candidate.isAgent && candidate.pubkey
|
||||
? profiles?.[pubkey]?.ownerPubkey
|
||||
: null) ??
|
||||
null,
|
||||
isManagedAgent: current.isManagedAgent || candidate.isManagedAgent,
|
||||
});
|
||||
};
|
||||
@@ -339,6 +355,7 @@ export function useMentions(
|
||||
member.role === "bot" ||
|
||||
managedAgentNamesByPubkey.has(pubkey) ||
|
||||
relayAgentNamesByPubkey.has(pubkey),
|
||||
ownerPubkey: profile?.ownerPubkey ?? null,
|
||||
personaName: personaNameByPubkey.get(pubkey) ?? null,
|
||||
role: member.role,
|
||||
secondaryLabel:
|
||||
@@ -544,92 +561,44 @@ export function useMentions(
|
||||
return [];
|
||||
}
|
||||
|
||||
const lowerQuery = mentionQuery.toLowerCase();
|
||||
|
||||
// Score a label against the query using word-boundary prefix matching.
|
||||
// Returns 0 if the full label starts with the query (best), 1 if any
|
||||
// word within the label starts with the query, or null if there's no
|
||||
// match. No arbitrary substring matching — standard for mention UX.
|
||||
const scoreLabel = (label: string): number | null => {
|
||||
const lower = label.toLowerCase();
|
||||
if (lower.startsWith(lowerQuery)) return 0;
|
||||
const words = lower.split(/[\s\-_]+/).filter(Boolean);
|
||||
if (words.some((word) => word.startsWith(lowerQuery))) return 1;
|
||||
return null;
|
||||
};
|
||||
|
||||
return mentionCandidates
|
||||
.map((candidate, order) => {
|
||||
const pubkeyLower = candidate.pubkey
|
||||
? normalizePubkey(candidate.pubkey)
|
||||
: "";
|
||||
const label =
|
||||
candidate.displayName ?? candidate.pubkey?.slice(0, 8) ?? "persona";
|
||||
const groupRank =
|
||||
candidate.kind === "persona" ||
|
||||
(candidate.personaId
|
||||
? activePersonaIds.has(candidate.personaId)
|
||||
: false)
|
||||
? 0
|
||||
: candidate.isMember
|
||||
? 1
|
||||
: 2;
|
||||
|
||||
const labelScores = [
|
||||
candidate.displayName,
|
||||
candidate.personaName,
|
||||
candidate.secondaryLabel,
|
||||
]
|
||||
.map((value) => (value ? scoreLabel(value) : null))
|
||||
.filter((score): score is number => score !== null);
|
||||
const labelScore =
|
||||
labelScores.length > 0 ? Math.min(...labelScores) : null;
|
||||
|
||||
const pubkeyScore = candidate.pubkey
|
||||
? pubkeyLower.startsWith(lowerQuery)
|
||||
? 3
|
||||
: pubkeyLower.includes(lowerQuery)
|
||||
? 4
|
||||
: null
|
||||
: null;
|
||||
const score = labelScore !== null ? labelScore : pubkeyScore;
|
||||
|
||||
return rankMentionCandidates(
|
||||
mentionCandidates,
|
||||
mentionQuery,
|
||||
activePersonaIds,
|
||||
)
|
||||
.slice(0, MENTION_SUGGESTION_LIMIT)
|
||||
.map(({ candidate, label }) => {
|
||||
const ownerLabel = candidate.isAgent
|
||||
? formatOwnerLabel(
|
||||
candidate.ownerPubkey,
|
||||
currentPubkey,
|
||||
ownerProfilesQuery.data?.profiles,
|
||||
)
|
||||
: null;
|
||||
const notInChannel =
|
||||
options?.channelType !== "dm" && candidate.isMember === false;
|
||||
|
||||
return { candidate, groupRank, label, order, ownerLabel, score };
|
||||
})
|
||||
.filter(
|
||||
(item): item is typeof item & { score: number } => item.score !== null,
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
a.groupRank - b.groupRank || a.score - b.score || a.order - b.order,
|
||||
)
|
||||
.slice(0, MENTION_SUGGESTION_LIMIT)
|
||||
.map(({ candidate, label, ownerLabel }) => ({
|
||||
pubkey: candidate.pubkey,
|
||||
personaId: candidate.personaId,
|
||||
kind: candidate.kind,
|
||||
displayName: label,
|
||||
avatarUrl:
|
||||
candidate.avatarUrl ??
|
||||
(candidate.pubkey
|
||||
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
|
||||
: null) ??
|
||||
null,
|
||||
isAgent: candidate.isAgent,
|
||||
notInChannel:
|
||||
options?.channelType !== "dm" && candidate.isMember === false,
|
||||
ownerLabel,
|
||||
role: !candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
}));
|
||||
return {
|
||||
pubkey: candidate.pubkey,
|
||||
personaId: candidate.personaId,
|
||||
kind: candidate.kind,
|
||||
displayName: label,
|
||||
avatarUrl:
|
||||
candidate.avatarUrl ??
|
||||
(candidate.pubkey
|
||||
? profiles?.[normalizePubkey(candidate.pubkey)]?.avatarUrl
|
||||
: null) ??
|
||||
null,
|
||||
isAgent: candidate.isAgent,
|
||||
notInChannel,
|
||||
ownerLabel,
|
||||
role:
|
||||
!candidate.isAgent && candidate.role === "admin" ? "admin" : null,
|
||||
};
|
||||
});
|
||||
}, [
|
||||
activePersonaIds,
|
||||
currentPubkey,
|
||||
mentionCandidates,
|
||||
mentionQuery,
|
||||
options?.channelType,
|
||||
|
||||
@@ -138,14 +138,18 @@ export const MentionAutocomplete = React.memo(function MentionAutocomplete({
|
||||
<span
|
||||
className="min-w-0 truncate"
|
||||
title={
|
||||
suggestion.ownerLabel
|
||||
? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}`
|
||||
: "not in channel"
|
||||
suggestion.ownerLabel && suggestion.notInChannel
|
||||
? `owned by ${suggestion.ownerLabel} · not in channel`
|
||||
: suggestion.ownerLabel
|
||||
? `owned by ${suggestion.ownerLabel}`
|
||||
: "not in channel"
|
||||
}
|
||||
>
|
||||
{suggestion.ownerLabel
|
||||
? `owned by ${suggestion.ownerLabel}${suggestion.notInChannel ? " · not in channel" : ""}`
|
||||
: "not in channel"}
|
||||
{suggestion.ownerLabel && suggestion.notInChannel
|
||||
? `owned by ${suggestion.ownerLabel} · not in channel`
|
||||
: suggestion.ownerLabel
|
||||
? `owned by ${suggestion.ownerLabel}`
|
||||
: "not in channel"}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
|
||||
@@ -160,7 +160,7 @@ async function expectAgentProfileMessageOnly(
|
||||
).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("@ trigger shows unified autocomplete with agents first", async ({
|
||||
test("@ trigger prioritizes channel members before runnable personas and other agents", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
@@ -205,10 +205,9 @@ test("@ trigger shows unified autocomplete with agents first", async ({
|
||||
expect(bobIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(charlieIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(outsiderIndex).toEqual(-1);
|
||||
expect(fizzIndex).toBeLessThan(aliceIndex);
|
||||
expect(fizzIndex).toBeLessThan(bobIndex);
|
||||
expect(aliceIndex).toBeLessThan(charlieIndex);
|
||||
expect(bobIndex).toBeLessThan(charlieIndex);
|
||||
expect(aliceIndex).toBeLessThan(fizzIndex);
|
||||
expect(bobIndex).toBeLessThan(fizzIndex);
|
||||
expect(fizzIndex).toBeLessThan(charlieIndex);
|
||||
});
|
||||
|
||||
test("thread autocomplete keeps multiple long names readable in a narrow panel", async ({
|
||||
@@ -274,7 +273,7 @@ test("thread autocomplete keeps multiple long names readable in a narrow panel",
|
||||
row.getByTestId("mention-suggestion-avatar-fallback"),
|
||||
).toBeVisible();
|
||||
await expect(row.getByText("agent")).toBeVisible();
|
||||
await expect(row.getByText(/owned by npub1mock/)).toBeVisible();
|
||||
await expect(row.getByText("owned by you")).toBeVisible();
|
||||
|
||||
await expect(row.getByText(name)).not.toHaveCSS(
|
||||
"text-overflow",
|
||||
|
||||
Reference in New Issue
Block a user