mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
## 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.   *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>
182 lines
6.3 KiB
TypeScript
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;
|
|
}
|