Files
buzz/desktop/src/shared/lib/resolveMentionNames.ts
Logan Johnson ee883d73fb fix(desktop): bind duplicate mention selections to exact recipients (#7133)
## Summary

Selecting two people or agents named Scout could replace the first
recipient with the second even though the message still looked right.
This binds each selection to its exact identity: the first keeps
`@Scout`, and a conflicting selection gets `@Scout (<full public key>)`.
Removing one no longer removes or redirects the other.

- Reuse the existing **@ suggestion list**; team selection and automatic
agent addressing reserve and reuse distinct labels too. Typing an
ambiguous name manually shows an instruction to use the picker and
preserves the draft without publishing—in chat, edits and standalone
forums.
- Share literal mention matching across recipient extraction, removal,
display and editing so a shorter name cannot claim another recipient's
longer or qualified label.
- Rebuild authored references when a message is edited, and use the
latest authorized snapshot when reopening or forwarding.
Rendering/editing can recover qualified identities only from the
message's recorded references, not from a key typed into its body.
Unresolvable historical names remain literal rather than guessed
recipients.
- Wrap full-key labels within narrow/zoomed layouts while keeping their
complete accessible label and ordinary mention icons. Edit activation
waits for the action menu's focus cleanup before focusing the editor.

### Related issue

Targets `main`; mention spacing (#7128) is already merged. Split from
#7114. This is independent of the #7124 → #7125 remote-invitation stack
and does not expand agent eligibility or invitation permissions. The
separate Enter-selection suffix issue remains tracked in #7253.

### Testing

The desktop unit suite and focused mock-Chromium checks passed on the
published integration candidate, including pending-paste selection,
edit/forwarding, copy and narrow-layout cases; formatting, types and
frontend builds passed. On `0b3b18c0`, 64 focused trust/paste/selection
unit tests and six mock-Chromium tests passed with zero browser retries,
including actual timeline chip copy → fresh channel paste → send and
mismatched-key rejection; TypeScript, changed-file Biome and an isolated
E2E build passed. The broader browser run had copy failures before the
focused repairs and is not claimed as wholly green. See [live
CI](https://github.com/block/buzz/pull/7133/checks) for current-head
results. No full local `just ci` pass, native/live-relay or
cross-browser validation is claimed.

To try it: select two same-name recipients, remove one, send, then
edit/reopen and forward; only the intended identities should remain.
Type an ambiguous name without choosing a suggestion and check that
sending retains the draft with an error. Inspect full-key labels in a
narrow window at 150% text size, and open Edit and type immediately.

![Ambiguous mention keeps the chat
draft](https://raw.githubusercontent.com/block/buzz/c48053209b36b3b1bf7197cf2d606d27b48a27c0/pr-7133--ambiguous-general.png)

![Full-key label in a narrow, zoomed
composer](https://raw.githubusercontent.com/block/buzz/ac262f00de6fe3860bff2f87dc0e98165ed6b651/pr-7133--layout-1.5-composer.png)

*Earlier mock-browser captures, not current-head runtime proof. No new
before-state capture; screenshots alone do not prove recipient
delivery.*

**Clipboard trust:** generated full-key-qualified mentions now retain
their exact recipient after copy/paste when the full key matches the
clipboard record and community directory/profile state independently
vouches for the base alias, including numeric collision suffixes. A
qualifier alone does not establish trust. Arbitrary historical labels
still cannot always be reconstructed.

---------

Signed-off-by: Logan Johnson <loganj@squareup.com>
Co-authored-by: Larry <627498bd4bd1f281a16431e3c6cce3b5c25b6692798c78672298aefbf2f8f8b5@buzz.block.builderlab.xyz>
Co-authored-by: Bad Janet <150b20bdf6130418df9239dd1bd082c71612c8d653b47c277200365b9be215dc@buzz>
2026-09-04 11:51:41 -04:00

182 lines
6.3 KiB
TypeScript

import { mentionOccurrences } from "./mentionOccurrences";
import type { UserProfileSummary } from "@/shared/api/types";
export const MENTION_REFERENCE_TAG = "mention";
export function getMentionTagPubkey(tag: string[]): string | null {
if ((tag[0] !== "p" && tag[0] !== MENTION_REFERENCE_TAG) || !tag[1]) {
return null;
}
return tag[1].toLowerCase();
}
/** An edit snapshot supersedes historical notification p-tags for body identity. */
export function mentionIdentityTags(tags: string[][] | undefined): string[][] {
const source = tags ?? [];
return source.some((tag) => tag[0] === "buzz:mention-snapshot")
? // Annotated references (e.g. immutable agent-address metadata) are not
// authored body bindings and must not leak into a fresh edit snapshot.
source.filter(
(tag) => tag[0] === MENTION_REFERENCE_TAG && tag.length === 2,
)
: source;
}
/**
* All names a profile can be @mentioned by. Message text is matched against
* the sender's view of the profile at send time (agents and the CLI resolve
* mentions against `display_name` *or* `name`, and renames happen after the
* fact), so a single-alias match leaves chips that render but never resolve
* to a pubkey. Emitting every known alias — display name, kind-0 `name`, and
* the NIP-05 local part — keeps rendered chips and pubkey resolution in sync.
*
* Exported because the clipboard's paste-side trust check has to ask the same
* question in reverse: a copied `label → pubkey` pair is only believable if
* the community's own profile for that pubkey answers to that label. Deriving
* both from this one alias set means a legitimate copy of a chip rendered off
* an alias cannot be refused for naming that alias.
*/
export function collectProfileAliases(
profile: UserProfileSummary | undefined,
): string[] {
if (!profile) {
return [];
}
const aliases: string[] = [];
const displayName = profile.displayName?.trim();
if (displayName) {
aliases.push(displayName);
}
const name = profile.name?.trim();
if (name) {
aliases.push(name);
}
// "_" is the NIP-05 root identifier, not a mentionable handle.
const nip05Local = profile.nip05Handle?.trim().split("@")[0]?.trim();
if (nip05Local && nip05Local !== "_") {
aliases.push(nip05Local);
}
return aliases;
}
export type ResolvedMentionProps = {
/** All literal competitors, including ambiguous aliases without a binding. */
mentionNames: string[] | undefined;
mentionPubkeysByName: Record<string, string> | undefined;
};
/**
* Resolves mention render names and the name→pubkey map for mentioned users
* from message `p` tags and non-notifying `mention` reference tags, in one
* pass over the tags.
*
* `p` tags drive notification/search semantics. `mention` tags only preserve
* render metadata for reference-only mentions.
*
* 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,
profiles: Record<string, UserProfileSummary> | undefined,
content = "",
): ResolvedMentionProps {
const taggedKeys = new Set(
mentionIdentityTags(tags)
.map(getMentionTagPubkey)
.filter((key): key is string => Boolean(key)),
);
const aliases = new Map<string, { displayName: string; keys: Set<string> }>();
const add = (label: string, key: string) => {
const normalized = label.toLowerCase();
const entry = aliases.get(normalized) ?? {
displayName: label,
keys: new Set<string>(),
};
entry.keys.add(key);
aliases.set(normalized, entry);
};
for (const key of taggedKeys) {
for (const alias of collectProfileAliases(profiles?.[key])) add(alias, key);
}
// Only event-tagged identities can supply qualified bindings. The body is not
// an authority to add recipients. Keep the historical literal label even if
// its profile has since been renamed or is not loaded.
const qualified: Array<{
displayName: string;
pubkey: string;
base: string;
}> = [];
for (const match of content.matchAll(
/@([^@\r\n]+) \(([0-9a-f]{64})\)(?: ((?:[1-9][0-9]+|[2-9])))?/gi,
)) {
const pubkey = match[2].toLowerCase();
if (!taggedKeys.has(pubkey)) continue;
const displayName = match[0].slice(1);
if (
!mentionOccurrences(content, [{ displayName }]).some(
(item) => item.start === match.index,
)
)
continue;
qualified.push({ displayName, pubkey, base: match[1].toLowerCase() });
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) {
let keys = [...entry.keys];
if (keys.length > 1) {
// A selected second Scout is explicitly qualified. Only a unique remaining
// Scout can own the unqualified label; tag iteration order is irrelevant.
keys = keys.filter(
(key) =>
!ownedQualified.some(
(item) => item.base === label && item.pubkey === key,
),
);
}
names.add(entry.displayName);
if (keys.length === 1) pubkeysByName[label] = keys[0];
}
return {
mentionNames: names.size > 0 ? [...names] : undefined,
mentionPubkeysByName: names.size > 0 ? pubkeysByName : undefined,
};
}
export function resolveMentionNames(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): string[] | undefined {
return resolveMentionProps(tags, profiles).mentionNames;
}
export function resolveMentionPubkeysByName(
tags: string[][] | undefined,
profiles: Record<string, UserProfileSummary> | undefined,
): Record<string, string> | undefined {
return resolveMentionProps(tags, profiles).mentionPubkeysByName;
}