mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
fix(desktop): preserve mention competitors through fallbacks
Compose edit fallback refs before exact occurrence selection. Keep ambiguous historical labels as non-authorizing blockers for rendering and body-based recipient fallbacks; qualified labels only disambiguate when they own an unambiguous occurrence. Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz> Signed-off-by: Logan Johnson <loganj@squareup.com> (cherry picked from commit 30545075a876136467c07d82abac83ad1904df23) Signed-off-by: Logan Johnson <loganj@squareup.com> (cherry picked from commit df75b5d1ac6d7aaa2bf0dfb4dfaf43b92539249a) Signed-off-by: Logan Johnson <loganj@squareup.com>
This commit is contained in:
@@ -121,23 +121,42 @@ export function snapshotDraftMentionRefs(
|
||||
selectedAgentNames: readonly string[],
|
||||
memberCandidates: readonly MentionPubkeyCandidate[] = [],
|
||||
selectedDisplayNames: Iterable<string> = [],
|
||||
fallbackRefs: readonly DraftMentionRef[] = [],
|
||||
): DraftMentionRef[] {
|
||||
// Edit-open refs are fallback bindings, not a second occurrence pass. Compose
|
||||
// them before matching against current selections, typed members and personas.
|
||||
const personaLabels = [...selectedDisplayNames];
|
||||
const currentNames = new Set(
|
||||
[...mentions.keys(), ...personaLabels].map((name) =>
|
||||
name.trim().toLowerCase(),
|
||||
),
|
||||
);
|
||||
const fallback = fallbackRefs.filter(
|
||||
(ref) => !currentNames.has(ref.displayName.trim().toLowerCase()),
|
||||
);
|
||||
const bindings = new Map([
|
||||
...fallback.map((ref) => [ref.displayName, ref.pubkey] as const),
|
||||
...mentions,
|
||||
]);
|
||||
const agentNames = new Set(
|
||||
selectedAgentNames.map((name) => name.trim().toLowerCase()),
|
||||
[
|
||||
...selectedAgentNames,
|
||||
...fallback.filter((ref) => ref.isAgent).map((ref) => ref.displayName),
|
||||
].map((name) => name.trim().toLowerCase()),
|
||||
);
|
||||
const presentNames = new Set(
|
||||
mentionOccurrences(
|
||||
content,
|
||||
mentionMatchCandidates({
|
||||
selectedMentions: mentions,
|
||||
selectedMentions: bindings,
|
||||
memberCandidates,
|
||||
selectedDisplayNames,
|
||||
selectedDisplayNames: personaLabels,
|
||||
}),
|
||||
).flatMap((match) =>
|
||||
match.candidates.map((candidate) => candidate.displayName),
|
||||
),
|
||||
);
|
||||
return [...mentions.entries()]
|
||||
return [...bindings.entries()]
|
||||
.filter(([displayName]) => presentNames.has(displayName))
|
||||
.map(([displayName, pubkey]) => ({
|
||||
displayName,
|
||||
|
||||
@@ -9,9 +9,15 @@ export function getVisibleAgentAddressPubkeys(
|
||||
body: string,
|
||||
addressedPubkeys: readonly string[],
|
||||
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
|
||||
mentionNames?: readonly string[],
|
||||
): string[] {
|
||||
const inlineMentionPubkeys = new Set(
|
||||
orderMentionPubkeysByText(body, mentionPubkeysByName, () => true),
|
||||
orderMentionPubkeysByText(
|
||||
body,
|
||||
mentionPubkeysByName,
|
||||
() => true,
|
||||
mentionNames,
|
||||
),
|
||||
);
|
||||
|
||||
return addressedPubkeys.filter(
|
||||
|
||||
222
desktop/src/features/messages/lib/mentionFallbacks.test.mjs
Normal file
222
desktop/src/features/messages/lib/mentionFallbacks.test.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import {
|
||||
buildEditMentionState,
|
||||
snapshotDraftMentionRefs,
|
||||
} from "./draftMentionRefs.ts";
|
||||
import { extractMentionPubkeys } from "./extractMentionPubkeys.ts";
|
||||
import { getSendToChannelSemantics } from "./sendToChannelSemantics.ts";
|
||||
import { getVisibleAgentAddressPubkeys } from "./getVisibleAgentAddressPubkeys.ts";
|
||||
import { resolveMentionProps } from "../../../shared/lib/resolveMentionNames.ts";
|
||||
import { submitMessageEdit } from "../ui/submitMessageEdit.ts";
|
||||
|
||||
const A = "a".repeat(64),
|
||||
B = "b".repeat(64),
|
||||
C = "c".repeat(64);
|
||||
const originalRef = { displayName: "Scout", pubkey: A, isAgent: true };
|
||||
|
||||
for (const replacement of ["member", "persona", "selected", "ambiguous"]) {
|
||||
test(`edit fallback competes with longer ${replacement} before save and restore`, async () => {
|
||||
const members =
|
||||
replacement === "member" || replacement === "ambiguous"
|
||||
? [
|
||||
{ displayName: "Scout Jones", pubkey: B, isMember: true },
|
||||
...(replacement === "ambiguous"
|
||||
? [{ displayName: "Scout Jones", pubkey: C, isMember: true }]
|
||||
: []),
|
||||
]
|
||||
: [];
|
||||
const bindings = new Map([
|
||||
["Scout", A],
|
||||
...(replacement === "selected" ? [["Scout Jones", B]] : []),
|
||||
]);
|
||||
const personas = replacement === "persona" ? ["Scout Jones"] : [];
|
||||
let saved,
|
||||
restored,
|
||||
cleared = false,
|
||||
error;
|
||||
const options = {
|
||||
content: "@Scout Jones hello",
|
||||
originalContent: "@Scout hello",
|
||||
editTarget: { mentionRefs: [originalRef], unresolvedMentionPubkeys: [] },
|
||||
editTargetId: "dummy-event",
|
||||
ownerPubkey: C,
|
||||
customEmoji: [],
|
||||
pendingImeta: [],
|
||||
queuedAttachments: [],
|
||||
spoileredAttachmentUrls: new Set(),
|
||||
getMentionRefs: (text, fallback) =>
|
||||
snapshotDraftMentionRefs(
|
||||
text,
|
||||
bindings,
|
||||
["Scout"],
|
||||
members,
|
||||
personas,
|
||||
fallback,
|
||||
),
|
||||
extractMentionPubkeys: (text) =>
|
||||
extractMentionPubkeys({
|
||||
text,
|
||||
selectedMentions: bindings,
|
||||
selectedDisplayNames: personas,
|
||||
memberCandidates: members,
|
||||
}),
|
||||
clearComposer() {
|
||||
cleared = true;
|
||||
},
|
||||
restoreComposer() {},
|
||||
restoreMentionRefs(refs) {
|
||||
restored = refs;
|
||||
},
|
||||
revalidateMentionPubkeys: async (keys) => [...keys],
|
||||
setDeferredUploadPending() {},
|
||||
shouldRestoreComposer: () => true,
|
||||
setUploadError(message) {
|
||||
error = message;
|
||||
},
|
||||
save: async (content, tags, notifying) => {
|
||||
saved = { content, tags, notifying };
|
||||
},
|
||||
};
|
||||
await submitMessageEdit(options);
|
||||
if (replacement === "ambiguous") {
|
||||
assert.match(error, /ambiguous/);
|
||||
assert.equal(cleared, false);
|
||||
assert.equal(saved, undefined);
|
||||
return;
|
||||
}
|
||||
assert.deepEqual(
|
||||
saved.tags ?? [],
|
||||
replacement === "selected" ? [["mention", B]] : [],
|
||||
);
|
||||
assert.deepEqual(saved.notifying, replacement === "persona" ? [] : [B]);
|
||||
await submitMessageEdit({
|
||||
...options,
|
||||
save: async () => {
|
||||
throw new Error("offline");
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
restored.map((ref) => ref.pubkey),
|
||||
replacement === "selected" ? [B] : [],
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("fallback keeps missing-profile refs but current same-name selection wins", () => {
|
||||
assert.deepEqual(
|
||||
snapshotDraftMentionRefs(
|
||||
"@Scout hello",
|
||||
new Map(),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[originalRef],
|
||||
),
|
||||
[originalRef],
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshotDraftMentionRefs(
|
||||
"@Scout hello",
|
||||
new Map([["Scout", B]]),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[originalRef],
|
||||
),
|
||||
[{ displayName: "Scout", pubkey: B, isAgent: false }],
|
||||
);
|
||||
assert.deepEqual(
|
||||
snapshotDraftMentionRefs(
|
||||
"@Scout Jones hello",
|
||||
new Map(),
|
||||
[],
|
||||
[],
|
||||
["Scout"],
|
||||
[originalRef],
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
for (const prefix of ["Scout", "Scout @Jones"]) {
|
||||
test(`ambiguous longer alias blocks ${prefix} in hydration and sibling fallbacks`, () => {
|
||||
const longer = `${prefix} Jones`;
|
||||
const profiles = {
|
||||
[A]: { displayName: prefix },
|
||||
[B]: { displayName: longer },
|
||||
[C]: { displayName: longer },
|
||||
};
|
||||
for (const keys of [
|
||||
[A, B, C],
|
||||
[C, B, A],
|
||||
]) {
|
||||
const tags = keys.map((key) => ["p", key]);
|
||||
const text = `@${longer} hello`;
|
||||
const props = resolveMentionProps(tags, profiles, text);
|
||||
assert.deepEqual(
|
||||
buildEditMentionState(text, tags, profiles, () => true).mentionRefs,
|
||||
[],
|
||||
);
|
||||
assert.deepEqual(
|
||||
new Set(
|
||||
buildEditMentionState(text, tags, profiles, () => true)
|
||||
.unresolvedMentionPubkeys,
|
||||
),
|
||||
new Set([A, B, C]),
|
||||
);
|
||||
assert.deepEqual(
|
||||
getVisibleAgentAddressPubkeys(
|
||||
text,
|
||||
[A],
|
||||
props.mentionPubkeysByName,
|
||||
props.mentionNames,
|
||||
),
|
||||
[A],
|
||||
);
|
||||
assert.deepEqual(
|
||||
getSendToChannelSemantics(
|
||||
{ body: text, edited: true, pubkey: "d".repeat(64), tags },
|
||||
profiles,
|
||||
).mentionPubkeys,
|
||||
[],
|
||||
);
|
||||
// Blocking one occurrence must not erase a separate valid shorter one.
|
||||
const mixed = `${text} @${prefix}!`;
|
||||
assert.deepEqual(
|
||||
buildEditMentionState(mixed, tags, profiles, () => true).mentionRefs,
|
||||
[{ displayName: prefix, pubkey: A, isAgent: true }],
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a shadowed or ambiguous qualified alias cannot disambiguate another occurrence", () => {
|
||||
for (const suffix of [" Jones", ""]) {
|
||||
const qualified = `Scout (${B})`;
|
||||
const longer = `${qualified}${suffix}`;
|
||||
const content = `@Scout and @${longer} hello`;
|
||||
const state = buildEditMentionState(
|
||||
content,
|
||||
[
|
||||
["p", A],
|
||||
["p", B],
|
||||
["p", C],
|
||||
],
|
||||
{
|
||||
[A]: { displayName: "Scout" },
|
||||
[B]: { displayName: "Scout" },
|
||||
[C]: { displayName: longer },
|
||||
},
|
||||
() => true,
|
||||
);
|
||||
assert.equal(
|
||||
state.mentionRefs.some((ref) => ref.pubkey === A),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(
|
||||
state.mentionRefs.map((ref) => ref.pubkey),
|
||||
suffix ? [C] : [],
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -25,3 +25,14 @@ test("dedupes aliases at their earliest authored position", () => {
|
||||
|
||||
assert.deepEqual(ordered, [AGENT_A, AGENT_B]);
|
||||
});
|
||||
|
||||
test("case variants cannot overwrite a competing identity before occurrence selection", () => {
|
||||
assert.deepEqual(
|
||||
orderMentionPubkeysByText(
|
||||
"@Scout hello",
|
||||
{ Scout: AGENT_A, scout: AGENT_B },
|
||||
() => true,
|
||||
),
|
||||
[],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -5,19 +5,36 @@ export function orderMentionPubkeysByText(
|
||||
text: string,
|
||||
mentionPubkeysByName: Readonly<Record<string, string>> | undefined,
|
||||
isEligible: (pubkey: string) => boolean,
|
||||
mentionNames: readonly string[] = Object.keys(mentionPubkeysByName ?? {}),
|
||||
): string[] {
|
||||
if (!mentionPubkeysByName) return [];
|
||||
|
||||
const earliestOffsetByPubkey = new Map<string, number>();
|
||||
const candidates = Object.entries(mentionPubkeysByName).map(
|
||||
const bindings = Object.entries(mentionPubkeysByName).map(
|
||||
([displayName, pubkey]) => ({ displayName, pubkey }),
|
||||
);
|
||||
const boundNames = new Set(
|
||||
bindings.map((item) => item.displayName.toLowerCase()),
|
||||
);
|
||||
const candidates = [
|
||||
...bindings,
|
||||
...mentionNames
|
||||
.filter((name) => !boundNames.has(name.toLowerCase()))
|
||||
.map((displayName) => ({ displayName, pubkey: undefined })),
|
||||
];
|
||||
for (const { start, candidates: winners } of mentionOccurrences(
|
||||
text,
|
||||
candidates,
|
||||
)) {
|
||||
if (new Set(winners.map((item) => normalizePubkey(item.pubkey))).size !== 1)
|
||||
if (
|
||||
new Set(
|
||||
winners.map((item) =>
|
||||
item.pubkey ? normalizePubkey(item.pubkey) : undefined,
|
||||
),
|
||||
).size !== 1
|
||||
)
|
||||
continue;
|
||||
if (!winners[0].pubkey) continue;
|
||||
const normalized = normalizePubkey(winners[0].pubkey);
|
||||
if (isEligible(normalized) && !earliestOffsetByPubkey.has(normalized))
|
||||
earliestOffsetByPubkey.set(normalized, start);
|
||||
|
||||
@@ -32,6 +32,11 @@ export function getSendToChannelSemantics(
|
||||
);
|
||||
const seenMentions = new Set<string>();
|
||||
const mentionPubkeys: string[] = [];
|
||||
const mentionProps = resolveMentionProps(
|
||||
message.tags,
|
||||
profiles,
|
||||
message.body,
|
||||
);
|
||||
const effectiveMentionPubkeys = message.edited
|
||||
? new Set(
|
||||
(message.tags ?? []).some((tag) => tag[0] === "buzz:mention-snapshot")
|
||||
@@ -41,9 +46,9 @@ export function getSendToChannelSemantics(
|
||||
.filter((pubkey) => PUBKEY_PATTERN.test(pubkey))
|
||||
: orderMentionPubkeysByText(
|
||||
message.body,
|
||||
resolveMentionProps(message.tags, profiles, message.body)
|
||||
.mentionPubkeysByName,
|
||||
mentionProps.mentionPubkeysByName,
|
||||
() => true,
|
||||
mentionProps.mentionNames,
|
||||
),
|
||||
)
|
||||
: null;
|
||||
|
||||
@@ -19,17 +19,21 @@ export function useDraftMentionRouting(params: {
|
||||
setSelectedNames: (names: string[]) => void;
|
||||
setSelectedAgentNames: (names: string[]) => void;
|
||||
}): {
|
||||
getDraftMentionRefs: (content: string) => DraftMentionRef[];
|
||||
getDraftMentionRefs: (
|
||||
content: string,
|
||||
fallbackRefs?: readonly DraftMentionRef[],
|
||||
) => DraftMentionRef[];
|
||||
restoreDraftMentionRefs: (refs: readonly DraftMentionRef[]) => void;
|
||||
} {
|
||||
const getDraftMentionRefs = React.useCallback(
|
||||
(content: string) =>
|
||||
(content: string, fallbackRefs: readonly DraftMentionRef[] = []) =>
|
||||
snapshotDraftMentionRefs(
|
||||
content,
|
||||
params.mentionMapRef.current,
|
||||
params.selectedAgentNamesRef.current,
|
||||
params.memberCandidates,
|
||||
params.personaMentionMapRef.current.keys(),
|
||||
fallbackRefs,
|
||||
),
|
||||
[
|
||||
params.mentionMapRef,
|
||||
|
||||
@@ -1,10 +1,43 @@
|
||||
import * as React from "react";
|
||||
import { getAgentAddressMentionPubkeys } from "../lib/agentAddressMention.mjs";
|
||||
import { getVisibleAgentAddressPubkeys } from "../lib/getVisibleAgentAddressPubkeys";
|
||||
|
||||
import type { UserProfileLookup } from "@/features/profile/lib/identity";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { truncatePubkey } from "@/shared/lib/pubkey";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
|
||||
/** Resolve all literal competitors before hiding tag-backed address chips. */
|
||||
export function useMessageAgentAddressPrefix({
|
||||
profiles,
|
||||
body,
|
||||
tags,
|
||||
mentionNames,
|
||||
mentionPubkeysByName,
|
||||
isKnownAgentPubkey,
|
||||
}: {
|
||||
profiles?: UserProfileLookup;
|
||||
body: string;
|
||||
tags?: string[][];
|
||||
mentionNames?: string[];
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
isKnownAgentPubkey: (pubkey: string) => boolean;
|
||||
}) {
|
||||
const pubkeys = React.useMemo(
|
||||
() =>
|
||||
getVisibleAgentAddressPubkeys(
|
||||
body,
|
||||
getAgentAddressMentionPubkeys(tags).filter(isKnownAgentPubkey),
|
||||
mentionPubkeysByName,
|
||||
mentionNames,
|
||||
),
|
||||
[body, tags, isKnownAgentPubkey, mentionPubkeysByName, mentionNames],
|
||||
);
|
||||
return pubkeys.length > 0 ? (
|
||||
<MessageAgentAddressPrefix profiles={profiles} pubkeys={pubkeys} />
|
||||
) : undefined;
|
||||
}
|
||||
|
||||
/** Visible send-state prefix for recipients kept in the composer address tray. */
|
||||
export function MessageAgentAddressPrefix({
|
||||
profiles,
|
||||
|
||||
@@ -57,9 +57,7 @@ import { MessageTimestamp } from "./MessageTimestamp";
|
||||
import { SentFromThreadLine } from "./SentFromThreadLine";
|
||||
import { WaveMessageAttachment } from "./WaveMessageAttachment";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
|
||||
import { getAgentAddressMentionPubkeys } from "@/features/messages/lib/agentAddressMention.mjs";
|
||||
import { getVisibleAgentAddressPubkeys } from "@/features/messages/lib/getVisibleAgentAddressPubkeys";
|
||||
import { MessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
|
||||
import { useMessageAgentAddressPrefix } from "./MessageAgentAddressPrefix";
|
||||
const DiffMessage = React.lazy(() => import("./DiffMessage"));
|
||||
const DiffMessageExpanded = React.lazy(() => import("./DiffMessageExpanded"));
|
||||
export type ThreadDepthGuideAction = {
|
||||
@@ -276,20 +274,14 @@ export const MessageRow = React.memo(
|
||||
}
|
||||
return Object.keys(values).length > 0 ? values : undefined;
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName]);
|
||||
const addressedAgentPubkeys = React.useMemo(() => {
|
||||
return getVisibleAgentAddressPubkeys(
|
||||
message.body,
|
||||
getAgentAddressMentionPubkeys(message.tags).filter(isKnownAgentPubkey),
|
||||
mentionPubkeysByName,
|
||||
);
|
||||
}, [isKnownAgentPubkey, mentionPubkeysByName, message.body, message.tags]);
|
||||
const agentAddressPrefix =
|
||||
addressedAgentPubkeys.length > 0 ? (
|
||||
<MessageAgentAddressPrefix
|
||||
profiles={profiles}
|
||||
pubkeys={addressedAgentPubkeys}
|
||||
/>
|
||||
) : undefined;
|
||||
const agentAddressPrefix = useMessageAgentAddressPrefix({
|
||||
profiles,
|
||||
body: message.body,
|
||||
tags: message.tags,
|
||||
mentionNames,
|
||||
mentionPubkeysByName,
|
||||
isKnownAgentPubkey,
|
||||
});
|
||||
const imetaByUrl = React.useMemo(
|
||||
() => (message.tags ? parseImetaTags(message.tags) : undefined),
|
||||
[message.tags],
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { AgentMentionAuthorizationError } from "../lib/agentMentionRevalidation.ts";
|
||||
import { snapshotDraftMentionRefs } from "../lib/draftMentionRefs.ts";
|
||||
import { submitMessageEdit } from "./submitMessageEdit.ts";
|
||||
|
||||
const UNRESOLVED_USER = "b".repeat(64);
|
||||
@@ -23,7 +24,8 @@ function baseOptions(
|
||||
editTarget,
|
||||
editTargetId: "event-id",
|
||||
extractMentionPubkeys: () => [],
|
||||
getMentionRefs: () => [],
|
||||
getMentionRefs: (text, fallback) =>
|
||||
snapshotDraftMentionRefs(text, new Map(), [], [], [], fallback),
|
||||
originalContent: content,
|
||||
ownerPubkey: "a".repeat(64),
|
||||
pendingImeta: [],
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
} from "@/features/messages/lib/agentMentionRevalidation";
|
||||
import type { QueuedMediaAttachment } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { enqueueBackgroundMediaUpload } from "@/features/messages/lib/backgroundMediaUploadStore";
|
||||
import { mentionOccurrences } from "@/shared/lib/mentionOccurrences";
|
||||
import type { DraftMentionRef } from "@/features/messages/lib/useDrafts";
|
||||
import type { MessageComposerEditTarget } from "@/features/messages/ui/MessageComposer.types";
|
||||
import {
|
||||
@@ -33,7 +32,10 @@ type SubmitMessageEditOptions = Omit<
|
||||
clearComposer: () => void;
|
||||
customEmoji: ReadonlyArray<CustomEmoji>;
|
||||
extractMentionPubkeys: (content: string) => string[];
|
||||
getMentionRefs: (content: string) => DraftMentionRef[];
|
||||
getMentionRefs: (
|
||||
content: string,
|
||||
fallbackRefs: readonly DraftMentionRef[],
|
||||
) => DraftMentionRef[];
|
||||
editTargetId: string;
|
||||
enqueueUpload?: typeof enqueueBackgroundMediaUpload;
|
||||
editTarget: Pick<
|
||||
@@ -82,25 +84,9 @@ export async function submitMessageEdit({
|
||||
setUploadError,
|
||||
spoileredAttachmentUrls,
|
||||
}: SubmitMessageEditOptions): Promise<void> {
|
||||
const selectedRefs = getMentionRefs(content);
|
||||
const selectedNames = new Set(
|
||||
selectedRefs.map((ref) => ref.displayName.toLowerCase()),
|
||||
);
|
||||
const currentMentionRefs = [
|
||||
...selectedRefs,
|
||||
...(editTarget.mentionRefs ?? []).filter(
|
||||
(ref) => !selectedNames.has(ref.displayName.toLowerCase()),
|
||||
),
|
||||
];
|
||||
const draft: EditDraft = {
|
||||
content,
|
||||
mentionRefs: [
|
||||
...new Set(
|
||||
mentionOccurrences(content, currentMentionRefs).flatMap(
|
||||
(match) => match.candidates,
|
||||
),
|
||||
),
|
||||
],
|
||||
mentionRefs: getMentionRefs(content, editTarget.mentionRefs ?? []),
|
||||
pendingImeta: [...pendingImeta],
|
||||
queuedAttachments: [...queuedAttachments],
|
||||
spoileredAttachmentUrls: new Set(spoileredAttachmentUrls),
|
||||
|
||||
@@ -47,6 +47,7 @@ function collectProfileAliases(
|
||||
}
|
||||
|
||||
export type ResolvedMentionProps = {
|
||||
/** All literal competitors, including ambiguous aliases without a binding. */
|
||||
mentionNames: string[] | undefined;
|
||||
mentionPubkeysByName: Record<string, string> | undefined;
|
||||
};
|
||||
@@ -59,8 +60,9 @@ export type ResolvedMentionProps = {
|
||||
* `p` tags drive notification/search semantics. `mention` tags only preserve
|
||||
* render metadata for reference-only mentions.
|
||||
*
|
||||
* Both outputs come from the same alias set, so any `@name` chip the markdown
|
||||
* renderer matches is guaranteed to resolve to a pubkey.
|
||||
* Recognition is separate from identity resolution: ambiguous aliases still
|
||||
* own their literal ranges but have no map entry. An explicit (possibly empty)
|
||||
* map tells renderers to leave those unbound occurrences as plain text.
|
||||
*/
|
||||
export function resolveMentionProps(
|
||||
tags: string[][] | undefined,
|
||||
@@ -110,6 +112,18 @@ export function resolveMentionProps(
|
||||
add(displayName, pubkey);
|
||||
}
|
||||
|
||||
// Qualification can narrow an ambiguous base only when that literal is itself
|
||||
// an unambiguous winning occurrence, not a prefix of another tagged alias.
|
||||
const ownedAliases = new Set(
|
||||
mentionOccurrences(content, [...aliases.values()]).flatMap((match) =>
|
||||
match.candidates
|
||||
.filter((entry) => entry.keys.size === 1)
|
||||
.map((entry) => entry.displayName.toLowerCase()),
|
||||
),
|
||||
);
|
||||
const ownedQualified = qualified.filter((item) =>
|
||||
ownedAliases.has(item.displayName.toLowerCase()),
|
||||
);
|
||||
const names = new Set<string>();
|
||||
const pubkeysByName: Record<string, string> = {};
|
||||
for (const [label, entry] of aliases) {
|
||||
@@ -119,18 +133,18 @@ export function resolveMentionProps(
|
||||
// Scout can own the unqualified label; tag iteration order is irrelevant.
|
||||
keys = keys.filter(
|
||||
(key) =>
|
||||
!qualified.some((item) => item.base === label && item.pubkey === key),
|
||||
!ownedQualified.some(
|
||||
(item) => item.base === label && item.pubkey === key,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (keys.length !== 1) continue;
|
||||
names.add(entry.displayName);
|
||||
pubkeysByName[label] = keys[0];
|
||||
if (keys.length === 1) pubkeysByName[label] = keys[0];
|
||||
}
|
||||
|
||||
return {
|
||||
mentionNames: names.size > 0 ? [...names] : undefined,
|
||||
mentionPubkeysByName:
|
||||
Object.keys(pubkeysByName).length > 0 ? pubkeysByName : undefined,
|
||||
mentionPubkeysByName: names.size > 0 ? pubkeysByName : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1474,3 +1474,53 @@ test("renderEntityLinkAnchor keeps chip styling when interaction is disabled", (
|
||||
assert.match(html, /class="mention-chip\s/);
|
||||
assert.doesNotMatch(html, /<button/);
|
||||
});
|
||||
|
||||
test("ambiguous longer aliases stay literal rather than rendering a shorter tagged chip", async () => {
|
||||
const { resolveMentionProps } = await import("../lib/resolveMentionNames.ts");
|
||||
const a = "a".repeat(64),
|
||||
b = "b".repeat(64),
|
||||
c = "c".repeat(64);
|
||||
for (const includeShorter of [false, true]) {
|
||||
const content = `@Scout Jones hello${includeShorter ? " @Scout!" : ""}`;
|
||||
const props = resolveMentionProps(
|
||||
[
|
||||
["p", a],
|
||||
["p", b],
|
||||
["p", c],
|
||||
],
|
||||
{
|
||||
[a]: { displayName: "Scout" },
|
||||
[b]: { displayName: "Scout Jones" },
|
||||
[c]: { displayName: "Scout Jones" },
|
||||
},
|
||||
content,
|
||||
);
|
||||
const markdown = renderCachedMarkdown({
|
||||
components: createMarkdownComponents(false, false),
|
||||
content,
|
||||
mentionNames: props.mentionNames,
|
||||
variant: "ambiguous-prefix-test",
|
||||
});
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(
|
||||
MarkdownRuntimeContext.Provider,
|
||||
{
|
||||
value: {
|
||||
channels: [],
|
||||
mentionPubkeysByName: props.mentionPubkeysByName,
|
||||
onOpenChannel() {},
|
||||
onOpenEntityLink() {},
|
||||
onOpenMessageLink() {},
|
||||
relayOrigin: null,
|
||||
},
|
||||
},
|
||||
markdown,
|
||||
),
|
||||
);
|
||||
assert.match(html, /@Scout Jones hello/);
|
||||
assert.equal(
|
||||
(html.match(/data-mention=""/g) ?? []).length,
|
||||
includeShorter ? 1 : 0,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -13,8 +13,8 @@ import {
|
||||
resolveMessageLinkRenderTarget,
|
||||
type ParsedMessageLink,
|
||||
} from "@/features/messages/lib/messageLink";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { renderAudioMessageAttachment } from "@/features/messages/ui/AudioMessageAttachment";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { parseEntityLink } from "@/shared/lib/entityLink";
|
||||
@@ -23,7 +23,7 @@ import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import { useRelayOrigin } from "@/shared/lib/useRelayOrigin";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
import { ConfigNudgeCard } from "@/shared/ui/config-nudge-attachment";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { createMarkdownMention } from "./markdown/MarkdownMention";
|
||||
import { LinkPreviewList } from "@/shared/ui/link-preview-list";
|
||||
import { useSmoothCorners } from "@/shared/ui/smoothCorners";
|
||||
import {
|
||||
@@ -1576,48 +1576,7 @@ export function createMarkdownComponents(
|
||||
ul: ({ children }) => (
|
||||
<ul className={cn("list-disc", listClassName)}>{children}</ul>
|
||||
),
|
||||
mention: function MarkdownMention({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { agentMentionPubkeysByName, mentionPubkeysByName } =
|
||||
useMarkdownRuntime();
|
||||
const mentionText = String(children ?? "");
|
||||
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
||||
const pubkey = mentionPubkeysByName?.[mentionName];
|
||||
const isAgentMention =
|
||||
pubkey !== undefined &&
|
||||
agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
// Only chips that actually open a profile get the clickable affordance.
|
||||
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
|
||||
// cursor there promises a click that does nothing.
|
||||
const opensProfile = interactive && pubkey !== undefined;
|
||||
const mentionNode = (
|
||||
<InlineChip
|
||||
data-mention=""
|
||||
className={cn(isAgentMention && "agent-mention-highlight")}
|
||||
icon={isAgentMention ? "agent" : "human"}
|
||||
interactive={opensProfile}
|
||||
>
|
||||
{mentionLabel}
|
||||
</InlineChip>
|
||||
);
|
||||
|
||||
return opensProfile ? (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={mentionLabel}
|
||||
pubkey={pubkey}
|
||||
role={isAgentMention ? "bot" : undefined}
|
||||
triggerElement="span"
|
||||
>
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
mentionNode
|
||||
);
|
||||
},
|
||||
mention: createMarkdownMention(interactive),
|
||||
emoji: ({ src, alt }: { src?: string; alt?: string }) => {
|
||||
const resolvedSrc = src ? rewriteRelayUrl(src) : src;
|
||||
if (!resolvedSrc) {
|
||||
|
||||
53
desktop/src/shared/ui/markdown/MarkdownMention.tsx
Normal file
53
desktop/src/shared/ui/markdown/MarkdownMention.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
import type * as React from "react";
|
||||
import { UserProfilePopover } from "@/features/profile/ui/UserProfilePopover";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { InlineChip } from "@/shared/ui/InlineChip";
|
||||
import { useMarkdownRuntime } from "./runtimeContext";
|
||||
|
||||
/** Bind interactivity once; names and identity resolution remain runtime inputs. */
|
||||
export function createMarkdownMention(interactive: boolean) {
|
||||
return function MarkdownMention({
|
||||
children,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { agentMentionPubkeysByName, mentionPubkeysByName } =
|
||||
useMarkdownRuntime();
|
||||
const mentionText = String(children ?? "");
|
||||
const mentionName = mentionText.replace(/^@/, "").trim().toLowerCase();
|
||||
const pubkey = mentionPubkeysByName?.[mentionName];
|
||||
// Unbound literal competitors consume their full range, without a chip.
|
||||
if (mentionPubkeysByName && !pubkey) return mentionText;
|
||||
const isAgentMention =
|
||||
pubkey !== undefined &&
|
||||
agentMentionPubkeysByName?.[mentionName] === pubkey;
|
||||
const mentionLabel = mentionText.replace(/^@/, "");
|
||||
// Only chips that actually open a profile get the clickable affordance.
|
||||
// A mention whose pubkey didn't resolve stays a plain chip — a pointer
|
||||
// cursor there promises a click that does nothing.
|
||||
const opensProfile = interactive && pubkey !== undefined;
|
||||
const mentionNode = (
|
||||
<InlineChip
|
||||
data-mention=""
|
||||
className={cn(isAgentMention && "agent-mention-highlight")}
|
||||
icon={isAgentMention ? "agent" : "human"}
|
||||
interactive={opensProfile}
|
||||
>
|
||||
{mentionLabel}
|
||||
</InlineChip>
|
||||
);
|
||||
|
||||
return opensProfile ? (
|
||||
<UserProfilePopover
|
||||
botIdenticonValue={mentionLabel}
|
||||
pubkey={pubkey}
|
||||
role={isAgentMention ? "bot" : undefined}
|
||||
triggerElement="span"
|
||||
>
|
||||
{mentionNode}
|
||||
</UserProfilePopover>
|
||||
) : (
|
||||
mentionNode
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -274,3 +274,59 @@ test("selected duplicate labels survive send, reopen, replacement and second reo
|
||||
.poll(() => editPayload(secondReplacement))
|
||||
.toEqual({ references: [SECOND], notifying: [] });
|
||||
});
|
||||
|
||||
test("editing to a longer typed member drops the original shorter reference", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installMockBridge(page, {
|
||||
searchProfiles: [
|
||||
{ pubkey: FIRST, displayName: "Scout" },
|
||||
{ pubkey: SECOND, displayName: "Scout Jones" },
|
||||
],
|
||||
});
|
||||
await page.goto("/");
|
||||
await page.getByTestId("channel-general").click();
|
||||
const input = page.getByTestId("message-input");
|
||||
await input.fill("@Scout");
|
||||
await page.getByTestId(`mention-suggestion-${FIRST}`).click();
|
||||
await page.keyboard.type("hello");
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect.poll(() => recipients(page, "@Scout hello")).toEqual([[FIRST]]);
|
||||
const row = page
|
||||
.getByTestId("message-timeline")
|
||||
.getByTestId("message-row")
|
||||
.last();
|
||||
await expect(row).toContainText("Scout hello");
|
||||
await waitForAnimations(page);
|
||||
await row.hover();
|
||||
await row.getByRole("button", { name: "More actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Edit message" }).click();
|
||||
await expect(input).toHaveText("@Scout hello");
|
||||
await input.fill("@Scout Jones hello");
|
||||
await page.getByTestId("send-message").click();
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const call = window.__BUZZ_E2E_COMMAND_LOG__
|
||||
?.filter((call) => call.command === "edit_message")
|
||||
.at(-1);
|
||||
const input = (
|
||||
call?.payload as {
|
||||
input?: {
|
||||
content: string;
|
||||
mentionTags?: string[][];
|
||||
mentionPubkeys: string[];
|
||||
};
|
||||
}
|
||||
)?.input;
|
||||
return input?.content === "@Scout Jones hello"
|
||||
? {
|
||||
references: input.mentionTags ?? [],
|
||||
notifying: input.mentionPubkeys,
|
||||
}
|
||||
: null;
|
||||
}),
|
||||
)
|
||||
.toEqual({ references: [], notifying: [SECOND] });
|
||||
await expect(row).toContainText("Scout Jones hello");
|
||||
});
|
||||
|
||||
@@ -52,3 +52,14 @@ profile renames or missing profiles. Historical arbitrary labels are not stored:
|
||||
missing or genuinely ambiguous unqualified aliases remain unresolved,
|
||||
non-notifying references rather than guessed recipients. Edit regression coverage
|
||||
checks reference preservation, not a claim of new notifying edit `p` tags.
|
||||
|
||||
Occurrence recognition must retain every competing literal label before binding
|
||||
eligibility is applied. The historical resolver's `mentionNames` includes
|
||||
ambiguous aliases as blockers; its explicit `mentionPubkeysByName` map binds only
|
||||
resolved aliases. Renderers leave unbound occurrences as literal text. Body-based
|
||||
ordering and legacy send-to-channel fallback use both outputs, not the map alone.
|
||||
Edit-open fallback refs enter the same candidate composition as current selected
|
||||
refs, typed members and personas before presence selection; they are never matched
|
||||
in a separate, narrower pass. Current selections (including unbound personas)
|
||||
take precedence over same-label fallback refs. Historical unresolved identities
|
||||
still preserve non-notifying metadata, without claiming a literal binding.
|
||||
|
||||
Reference in New Issue
Block a user