diff --git a/desktop/src/features/messages/lib/draftMentionRefs.ts b/desktop/src/features/messages/lib/draftMentionRefs.ts index 0489364678..c115faf936 100644 --- a/desktop/src/features/messages/lib/draftMentionRefs.ts +++ b/desktop/src/features/messages/lib/draftMentionRefs.ts @@ -121,23 +121,42 @@ export function snapshotDraftMentionRefs( selectedAgentNames: readonly string[], memberCandidates: readonly MentionPubkeyCandidate[] = [], selectedDisplayNames: Iterable = [], + 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, diff --git a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts index f96279628f..54d76e8905 100644 --- a/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts +++ b/desktop/src/features/messages/lib/getVisibleAgentAddressPubkeys.ts @@ -9,9 +9,15 @@ export function getVisibleAgentAddressPubkeys( body: string, addressedPubkeys: readonly string[], mentionPubkeysByName: Readonly> | undefined, + mentionNames?: readonly string[], ): string[] { const inlineMentionPubkeys = new Set( - orderMentionPubkeysByText(body, mentionPubkeysByName, () => true), + orderMentionPubkeysByText( + body, + mentionPubkeysByName, + () => true, + mentionNames, + ), ); return addressedPubkeys.filter( diff --git a/desktop/src/features/messages/lib/mentionFallbacks.test.mjs b/desktop/src/features/messages/lib/mentionFallbacks.test.mjs new file mode 100644 index 0000000000..c38271fe0e --- /dev/null +++ b/desktop/src/features/messages/lib/mentionFallbacks.test.mjs @@ -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] : [], + ); + } +}); diff --git a/desktop/src/features/messages/lib/orderMentionPubkeys.test.mjs b/desktop/src/features/messages/lib/orderMentionPubkeys.test.mjs index 43d9270101..0c73442fb7 100644 --- a/desktop/src/features/messages/lib/orderMentionPubkeys.test.mjs +++ b/desktop/src/features/messages/lib/orderMentionPubkeys.test.mjs @@ -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, + ), + [], + ); +}); diff --git a/desktop/src/features/messages/lib/orderMentionPubkeys.ts b/desktop/src/features/messages/lib/orderMentionPubkeys.ts index d57cc99750..3c7555b5c1 100644 --- a/desktop/src/features/messages/lib/orderMentionPubkeys.ts +++ b/desktop/src/features/messages/lib/orderMentionPubkeys.ts @@ -5,19 +5,36 @@ export function orderMentionPubkeysByText( text: string, mentionPubkeysByName: Readonly> | undefined, isEligible: (pubkey: string) => boolean, + mentionNames: readonly string[] = Object.keys(mentionPubkeysByName ?? {}), ): string[] { if (!mentionPubkeysByName) return []; const earliestOffsetByPubkey = new Map(); - 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); diff --git a/desktop/src/features/messages/lib/sendToChannelSemantics.ts b/desktop/src/features/messages/lib/sendToChannelSemantics.ts index ebdbeb4b49..c53338a74a 100644 --- a/desktop/src/features/messages/lib/sendToChannelSemantics.ts +++ b/desktop/src/features/messages/lib/sendToChannelSemantics.ts @@ -32,6 +32,11 @@ export function getSendToChannelSemantics( ); const seenMentions = new Set(); 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; diff --git a/desktop/src/features/messages/lib/useDraftMentionRouting.ts b/desktop/src/features/messages/lib/useDraftMentionRouting.ts index 13ebb9e73a..ddcbdba6bb 100644 --- a/desktop/src/features/messages/lib/useDraftMentionRouting.ts +++ b/desktop/src/features/messages/lib/useDraftMentionRouting.ts @@ -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, diff --git a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx index c43d6fa981..fca283a656 100644 --- a/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx +++ b/desktop/src/features/messages/ui/MessageAgentAddressPrefix.tsx @@ -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; + 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 ? ( + + ) : undefined; +} + /** Visible send-state prefix for recipients kept in the composer address tray. */ export function MessageAgentAddressPrefix({ profiles, diff --git a/desktop/src/features/messages/ui/MessageRow.tsx b/desktop/src/features/messages/ui/MessageRow.tsx index 488a006726..09e2391444 100644 --- a/desktop/src/features/messages/ui/MessageRow.tsx +++ b/desktop/src/features/messages/ui/MessageRow.tsx @@ -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 ? ( - - ) : 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], diff --git a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs index 7d5735b948..feb4ede96d 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.test.mjs +++ b/desktop/src/features/messages/ui/submitMessageEdit.test.mjs @@ -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: [], diff --git a/desktop/src/features/messages/ui/submitMessageEdit.ts b/desktop/src/features/messages/ui/submitMessageEdit.ts index 06715a180e..4c1a624455 100644 --- a/desktop/src/features/messages/ui/submitMessageEdit.ts +++ b/desktop/src/features/messages/ui/submitMessageEdit.ts @@ -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; 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 { - 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), diff --git a/desktop/src/shared/lib/resolveMentionNames.ts b/desktop/src/shared/lib/resolveMentionNames.ts index 7e30eb24ee..7be11f8934 100644 --- a/desktop/src/shared/lib/resolveMentionNames.ts +++ b/desktop/src/shared/lib/resolveMentionNames.ts @@ -47,6 +47,7 @@ function collectProfileAliases( } export type ResolvedMentionProps = { + /** All literal competitors, including ambiguous aliases without a binding. */ mentionNames: string[] | undefined; mentionPubkeysByName: Record | 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(); const pubkeysByName: Record = {}; 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, }; } diff --git a/desktop/src/shared/ui/markdown.test.mjs b/desktop/src/shared/ui/markdown.test.mjs index 42a0afa052..44d6b16aab 100644 --- a/desktop/src/shared/ui/markdown.test.mjs +++ b/desktop/src/shared/ui/markdown.test.mjs @@ -1474,3 +1474,53 @@ test("renderEntityLinkAnchor keeps chip styling when interaction is disabled", ( assert.match(html, /class="mention-chip\s/); assert.doesNotMatch(html, /