diff --git a/src/bundled/mentions/session-agents.test.tsx b/src/bundled/mentions/session-agents.test.tsx index 6eef6dc01..b047eecae 100644 --- a/src/bundled/mentions/session-agents.test.tsx +++ b/src/bundled/mentions/session-agents.test.tsx @@ -42,6 +42,7 @@ import { createMessages } from "../../features/relay/messages"; import type { Outbox } from "../../features/relay/outbox"; import { MessageMarkdown } from "../../features/messages/MessageMarkdown"; import { profileTarget } from "../../features/profiles/target"; +import { npubEncode } from "nostr-tools/nip19"; afterEach(() => { cleanup(); vi.useRealTimers(); @@ -110,6 +111,7 @@ function setup(parent: boolean | null = true, archived = false) { subscribe: () => () => {}, snapshot: () => 0, resolve: (_key: string, fallback: string) => fallback, + scope: () => () => undefined, }, agentLibrary: library.queries, agentChoices: createAgentChoices({ @@ -1632,6 +1634,10 @@ it("uses base matches and visible lexical ties for Fizz in both chooser surfaces names: { ...names, resolve: (key: string) => rows.find((row) => row.key === key)?.label, + scope: () => (key: string) => { + const name = rows.find((row) => row.key === key)?.label; + return name ? { name, source: "agent-directory" as const } : undefined; + }, }, } satisfies RelaySession; const expected = [rows[5], rows[4], rows[1], rows[2], rows[3], rows[0]].map( @@ -1887,3 +1893,81 @@ it("the persistent toolbar picker reads an empty search again after close and re ); t.library.dispose(); }); + +it("names the choice set only while the picker is shown, and qualifies namesakes in both menus", async () => { + const t = setup(); + const member = "a".repeat(64), + namesake = "c".repeat(64); + const profiles = new Map([ + [member, { name: "Larry" }], + [namesake, { name: "Larry" }], + ]); + const list = { + status: "ready" as const, + channels: [ + { + id: "parent", + name: "Parent", + channelType: "stream" as const, + members: [member, namesake], + }, + ], + }; + const session = { + ...t.session, + profiles: { ...t.session.profiles, snapshot: () => profiles }, + channels: { ...t.session.channels, list: () => list }, + }; + const names = bindNames( + { profiles: session.profiles, agentLibrary: t.library.queries }, + { snapshot: () => [createAgentDirectory()], subscribe: () => () => {} }, + ); + const scope = vi.spyOn(names, "scope"); + const labels = [member, namesake].map( + (key) => `Larry · ${npubEncode(key).slice(-4)}`, + ); + const picker = (disabled: boolean) => ( + true} + /> + ); + const view = render(picker(false)); + view.rerender(picker(false)); + expect(scope).not.toHaveBeenCalled(); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Mention a member" })); + for (const [index, key] of [member, namesake].entries()) + expect( + await screen.findByRole("button", { name: `${labels[index]} ${key}` }), + ).toBeInTheDocument(); + view.rerender(picker(true)); + scope.mockClear(); + view.rerender(picker(true)); + expect(scope).not.toHaveBeenCalled(); + view.unmount(); + const publish = vi.fn(); + render( + , + ); + await waitFor(() => + expect( + (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items + .map((item) => item.label) + .sort(), + ).toEqual([...labels].sort()), + ); + names.dispose(); + t.library.dispose(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 82ae33ef1..ac250ff5e 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -94,9 +94,10 @@ export function useMentionChoices( ]; // Directory pages are menu-local, not cached profiles. Name them only when no // other source knows the key, so namesakes in this choice set are qualified. + const known = session.names?.scope(); const facts = [ ...people - .filter((person) => !session.names?.resolve(person.pubkey)) + .filter((person) => !known?.(person.pubkey)) .map(({ pubkey, name, isAgent }) => ({ pubkey, name, @@ -104,15 +105,11 @@ export function useMentionChoices( })), ...selected, ]; + // One naming scope per choice set, not one normalization per row. + const name = session.names?.scope(keys, facts); return candidates.map((choice) => ({ ...choice, - label: - session.names?.resolve( - choice.recipient.pubkey, - choice.recipient.name, - keys, - facts, - ) ?? choice.recipient.name, + label: name?.(choice.recipient.pubkey)?.name ?? choice.recipient.name, })); }, [session, channelId, invite, roster, selected, people]); const { candidates, local } = useMemo(() => { @@ -122,6 +119,8 @@ export function useMentionChoices( void agents; void archives; void resolve; + // A closed or disabled chooser shows nothing, so it names nothing. + if (!open) return { candidates: [], local: new Set() }; return { candidates: current(), // Keys known without the directory search: members and agent choices. @@ -132,6 +131,7 @@ export function useMentionChoices( ), }; }, [ + open, current, session, channelId, diff --git a/src/bundled/profiles/ProfilePanel.test.tsx b/src/bundled/profiles/ProfilePanel.test.tsx index 9a7384cc1..b1a1acc94 100644 --- a/src/bundled/profiles/ProfilePanel.test.tsx +++ b/src/bundled/profiles/ProfilePanel.test.tsx @@ -31,7 +31,7 @@ it("updates a mounted profile from the shared name view without replacing its id activate() { return undefined; }, - resolve: () => name, + scope: () => () => ({ name }), }, ], subscribe(listener) { diff --git a/src/features/identity-names/directory.test.ts b/src/features/identity-names/directory.test.ts index 8ec9e2533..a2dd07f77 100644 --- a/src/features/identity-names/directory.test.ts +++ b/src/features/identity-names/directory.test.ts @@ -2,7 +2,12 @@ import { expect, it, vi } from "vitest"; import type { AgentControlState, AgentView } from "../agents/control"; import { bindNames, type NameSource } from "./service"; import { npubEncode } from "nostr-tools/nip19"; -import { agentDirectory, createAgentDirectory } from "./testing"; +import { + agentDirectory, + createAgentDirectory, + defaultNamingPolicy, +} from "./testing"; +import { createNameProvider } from "./directory"; it("scopes native names to the session community and follows edits and disposal", () => { const key = "a".repeat(64); @@ -67,11 +72,12 @@ it("scopes native names to the session community and follows edits and disposal" for (const listener of listeners) listener(); expect(changed).toHaveBeenCalledOnce(); expect(names.resolve(key)).toBe("Edited"); - expect(provider.resolve({ ...source, relayUrl: undefined }, key)).toBe( + expect(provider.scope({ ...source, relayUrl: undefined })(key)?.name).toBe( "Legacy", ); expect( - provider.resolve({ ...source, relayUrl: "https://unrelated.example" }, key), + provider.scope({ ...source, relayUrl: "https://unrelated.example" })(key) + ?.name, ).toBe("Legacy"); state = { ...state, status: "error" }; expect(names.resolve(key)).toBe("Legacy"); @@ -109,14 +115,14 @@ it("suffixes equal names across profiles, without merging keys or suffixing uniq }, }; for (const key of [a, b, c, d]) { - expect(agentDirectory.resolve(source, key)).toBe( + expect(agentDirectory.scope(source)(key)?.name).toBe( `Bad Janet · ${npubEncode(key).slice(-4)}`, ); } - expect(agentDirectory.resolve(source, e)).toBe("Larry"); + expect(agentDirectory.scope(source)(e)?.name).toBe("Larry"); library = { ...library, identities: identities.slice(0, 2) }; - expect(agentDirectory.resolve(source, a)).toBe("Bad Janet"); - expect(agentDirectory.resolve(source, b)).toBeUndefined(); + expect(agentDirectory.scope(source)(a)?.name).toBe("Bad Janet"); + expect(agentDirectory.scope(source)(b)?.name).toBeUndefined(); }); it("recomputes collisions for native edits, community scope, and profile fallbacks", () => { @@ -183,7 +189,7 @@ it("recomputes collisions for native edits, community scope, and profile fallbac expect(names.resolve(a)).toBe("Native Larry"); expect(names.resolve(b)).toBe("Legacy"); expect( - provider.resolve({ ...source, relayUrl: "https://other.test" }, a), + provider.scope({ ...source, relayUrl: "https://other.test" })(a)?.name, ).toBe(`Legacy · ${npubEncode(a).slice(-4)}`); expect(agent.name).toBe("Native Larry"); @@ -244,18 +250,18 @@ it("includes native-only identities but ignores other-community and unready nati refresh: async () => {}, }); for (const key of [a, b]) - expect(provider.resolve(source, key)).toBe( + expect(provider.scope(source)(key)?.name).toBe( `Larry · ${npubEncode(key).slice(-4)}`, ); expect( - provider.resolve({ ...source, relayUrl: "https://elsewhere.test" }, a), + provider.scope({ ...source, relayUrl: "https://elsewhere.test" })(a)?.name, ).toBe("Larry"); expect( - provider.resolve({ ...source, relayUrl: "https://elsewhere.test" }, b), + provider.scope({ ...source, relayUrl: "https://elsewhere.test" })(b)?.name, ).toBeUndefined(); native = { ...native, status: "error" }; - expect(provider.resolve(source, a)).toBe("Larry"); - expect(provider.resolve(source, b)).toBeUndefined(); + expect(provider.scope(source)(a)?.name).toBe("Larry"); + expect(provider.scope(source)(b)?.name).toBeUndefined(); }); it("applies viewer and owner metadata through the shared view and follows owner edits", () => { @@ -296,7 +302,7 @@ it("applies viewer and owner metadata through the shared view and follows owner }); expect(names.resolve(a)).toBe("Honey"); expect(names.resolve(b)).toBe("Wes’s Honey"); - expect(provider.resolve({ ...source, viewer: other }, a)).toBe( + expect(provider.scope({ ...source, viewer: other })(a)?.name).toBe( "Logan’s Honey", ); expect(names.resolve(a)).toBe("Honey"); @@ -308,3 +314,72 @@ it("applies viewer and owner metadata through the shared view and follows owner expect(names.resolve(b)).toBe("Wesley’s Honey"); names.dispose(); }); + +it("reuses one policy run per candidate scope across mixed case, outside keys, and updates", () => { + const [a, b, c, d] = ["a", "b", "c", "d"].map((key) => key.repeat(64)); + if (!a || !b || !c || !d) throw new Error("Missing fixture keys"); + let profiles = new Map([ + [a, { name: "Alex" }], + [b, { name: "Alex" }], + [c, { name: "Alex" }], + [d, { name: "Dana" }], + ]); + const library = { + status: "ready", + definitions: [], + identities: [], + } as const; + const listeners = new Set<() => void>(); + const source: NameSource = { + profiles: { + snapshot: () => profiles, + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + ensure: async () => {}, + }, + agentLibrary: { + snapshot: () => library, + subscribe: () => () => {}, + refresh: async () => {}, + retain: () => () => {}, + }, + }; + const resolve = vi.fn(defaultNamingPolicy.resolve); + const names = bindNames(source, { + snapshot: () => [createNameProvider({ id: "spy", resolve })], + subscribe: () => () => {}, + }); + const suffixed = (key: string) => `Alex · ${npubEncode(key).slice(-4)}`; + const candidates = [a.toUpperCase(), b, d]; + const scoped = names.scope(candidates); + expect(scoped(a)?.name).toBe(suffixed(a)); + expect(scoped(b.toUpperCase())).toEqual({ + name: suffixed(b), + qualifier: npubEncode(b).slice(-4), + source: "agent-directory", + }); + expect(scoped(d)?.name).toBe("Dana"); + expect(names.lookup(b, candidates)).toEqual(scoped(b)); + // Interleaved unscoped lookups keep their own cached scope. + expect(names.resolve(d)).toBe("Dana"); + expect(scoped(a)?.name).toBe(suffixed(a)); + expect(resolve).toHaveBeenCalledTimes(2); + // An outside historical reference joins the scope only for its own lookup. + expect(names.scope([d])(a)?.name).toBe("Alex"); + expect(scoped(c)?.name).toBe(suffixed(c)); + expect(scoped(a)?.name).toBe(suffixed(a)); + expect(resolve).toHaveBeenCalledTimes(4); + // A hot scope survives a stream of distinct historical lookups. + for (const digit of "01234567") { + expect(names.scope([digit.repeat(64)])(a)?.name).toBe("Alex"); + expect(scoped(a)?.name).toBe(suffixed(a)); + } + expect(resolve).toHaveBeenCalledTimes(12); + profiles = new Map([...profiles, [b, { name: "Blake" }]]); + for (const listener of listeners) listener(); + expect(scoped(a)?.name).toBe("Alex"); + expect(scoped(b)?.name).toBe("Blake"); + expect(resolve).toHaveBeenCalledTimes(13); +}); diff --git a/src/features/identity-names/directory.ts b/src/features/identity-names/directory.ts index c2776a8e7..fa40d2e14 100644 --- a/src/features/identity-names/directory.ts +++ b/src/features/identity-names/directory.ts @@ -1,6 +1,5 @@ import type { NamingIdentity } from "./policy"; -import type { NamingPolicy } from "./service"; -import type { NameProvider } from "./service"; +import type { NameProvider, NameSource, NamingPolicy } from "./service"; import type { AgentControl } from "../agents/control"; import { relayOrigin } from "../communities/destination"; @@ -17,13 +16,99 @@ export function createNameProvider( policy: NamingPolicy, control?: Pick, ): NameProvider { - let cached: + let base: | { inputs: readonly unknown[]; names: Map; - suffixes: Map; + identities: Map; + // Recently used scopes (LRU), so a hot scope survives interleaved lookups. + scopes: Map< + string | undefined, + { displayFacts: unknown; labels: ReturnType } + >; } | undefined; + const current = (source: NameSource) => { + const native = control?.snapshot(); + const relayUrl = source.relayUrl; + const library = source.agentLibrary.snapshot(); + const profiles = source.profiles.snapshot(); + const inputs = [library, native, profiles, relayUrl, source.viewer]; + if (base && inputs.every((input, index) => input === base?.inputs[index])) + return base; + const names = new Map(); + if (library.status === "ready") { + for (const row of library.identities) { + const key = row.pubkey.toLowerCase(); + if (!names.has(key)) + names.set( + key, + row.name.trim() || profiles.get(key)?.name.trim() || "Agent", + ); + } + } + if (relayUrl && native?.status === "ready") { + const seen = new Set(); + for (const agent of native.data?.agents ?? []) { + const key = agent.pubkey.toLowerCase(); + if (seen.has(key) || !sameCommunity(agent.relayUrl, relayUrl)) continue; + seen.add(key); + const name = agent.name.trim(); + if (name) names.set(key, name); + } + } + const identities = new Map( + [...profiles].map(([pubkey, profile]) => [ + pubkey, + { pubkey, ...profile }, + ]), + ); + for (const [pubkey, name] of names) { + identities.set(pubkey, { + ...profiles.get(pubkey), + pubkey, + name, + isAgent: true, + }); + } + base = { inputs, names, identities, scopes: new Map() }; + return base; + }; + const labels = ( + source: NameSource, + selection: readonly string[] | undefined, + id: string | undefined, + displayFacts: readonly NamingIdentity[] | undefined, + ) => { + const { identities, scopes } = current(source); + const cached = scopes.get(id); + if (cached && cached.displayFacts === displayFacts) { + scopes.delete(id); + scopes.set(id, cached); + return cached.labels; + } + // View-local labels (authored draft text or cross-community management rows) + // supplement display facts only; they never select recipients or grant control. + const displayed = (displayFacts ?? []).map((fact) => { + const key = fact.pubkey.toLowerCase(); + return { ...identities.get(key), ...fact, pubkey: key }; + }); + const displayedKeys = new Set(displayed.map((fact) => fact.pubkey)); + const resolved = policy.resolve( + [ + ...[...identities.values()].filter( + (row) => !displayedKeys.has(row.pubkey), + ), + ...displayed, + ], + source.viewer, + selection, + ); + scopes.delete(id); + if (scopes.size >= 8) scopes.delete(scopes.keys().next().value); + scopes.set(id, { displayFacts, labels: resolved }); + return resolved; + }; return { id: policy.id, ...(control ? { subscribe: control.subscribe } : {}), @@ -31,96 +116,25 @@ export function createNameProvider( void control?.refresh(); return source.agentLibrary.retain(); }, - resolve(source, pubkey, candidates, displayFacts) { - const key = pubkey.toLowerCase(); - const native = control?.snapshot(); - const relayUrl = source.relayUrl; - const library = source.agentLibrary.snapshot(); - const profiles = source.profiles.snapshot(); - // Include the requested identity even for historical non-member references. - const selection = - candidates && - [ - ...new Set([...candidates, key].map((key) => key.toLowerCase())), - ].sort(); - const inputs = [ - library, - native, - profiles, - relayUrl, - source.viewer, - selection?.join(":"), - displayFacts, - ]; - if ( - !cached || - inputs.some((input, index) => input !== cached?.inputs[index]) - ) { - const names = new Map(); - if (library.status === "ready") { - for (const row of library.identities) { - const key = row.pubkey.toLowerCase(); - if (!names.has(key)) - names.set( - key, - row.name.trim() || profiles.get(key)?.name.trim() || "Agent", - ); - } - } - if (relayUrl && native?.status === "ready") { - const seen = new Set(); - for (const agent of native.data?.agents ?? []) { - const key = agent.pubkey.toLowerCase(); - if (seen.has(key) || !sameCommunity(agent.relayUrl, relayUrl)) - continue; - seen.add(key); - const name = agent.name.trim(); - if (name) names.set(key, name); - } - } - const identities = new Map( - [...profiles].map(([pubkey, profile]) => [ - pubkey, - { pubkey, ...profile }, - ]), - ); - for (const [pubkey, name] of names) { - identities.set(pubkey, { - ...profiles.get(pubkey), - pubkey, - name, - isAgent: true, - }); - } - // View-local labels (authored draft text or cross-community management rows) - // supplement display facts only; they never select recipients or grant control. - const displayed = (displayFacts ?? []).map((fact) => { - const key = fact.pubkey.toLowerCase(); - return { ...identities.get(key), ...fact, pubkey: key }; - }); - const displayedKeys = new Set(displayed.map((fact) => fact.pubkey)); - const resolved = policy.resolve( - [ - ...[...identities.values()].filter( - (row) => !displayedKeys.has(row.pubkey), - ), - ...displayed, - ], - source.viewer, - selection, - ); - const suffixes = new Map(); - for (const [key, label] of resolved) { - names.set(key, label.name); - if (label.qualifier) suffixes.set(key, label.qualifier); - } - cached = { inputs, names, suffixes }; - } - return cached.names.get(key); - }, - qualifier(source, pubkey, candidates, displayFacts) { - this.resolve(source, pubkey, candidates, displayFacts); - return cached?.suffixes.get(pubkey.toLowerCase()); + scope(source, candidates, displayFacts) { + const selected = + candidates && new Set(candidates.map((key) => key.toLowerCase())); + const selection = selected && [...selected].sort(); + const id = selection?.join(":"); + return (pubkey) => { + const key = pubkey.toLowerCase(); + // Include the requested identity even for historical non-member references. + const outside = + selected && !selected.has(key) && [...selected, key].sort(); + const label = ( + outside + ? labels(source, outside, outside.join(":"), displayFacts) + : labels(source, selection, id, displayFacts) + ).get(key); + if (label) return label; + const name = current(source).names.get(key); + return name ? { name } : undefined; + }; }, }; } diff --git a/src/features/identity-names/service.test.ts b/src/features/identity-names/service.test.ts index f4fa8efc2..ece8f7eaa 100644 --- a/src/features/identity-names/service.test.ts +++ b/src/features/identity-names/service.test.ts @@ -113,7 +113,11 @@ it("rebinds a live name view on plugin replacement and disable", async () => { it("ignores competing providers and cannot reactivate a disposed view", () => { const f = fixture(); const activate = vi.fn(); - const provider = { ...agentDirectory, activate, resolve: () => "Override" }; + const provider = { + ...agentDirectory, + activate, + scope: () => () => ({ name: "Override" }), + }; let entries = [provider, provider]; let update = () => {}; const view = bindNames(f.source, { diff --git a/src/features/identity-names/service.ts b/src/features/identity-names/service.ts index 6bf2bf264..8a9a895a6 100644 --- a/src/features/identity-names/service.ts +++ b/src/features/identity-names/service.ts @@ -30,19 +30,15 @@ export type NameSource = { }; export type NameProvider = { id: string; - resolve( + /** Resolve keys against one candidate scope; the requested key always joins it. */ + scope( source: NameSource, - pubkey: string, candidates?: readonly string[], displayFacts?: readonly NamingIdentity[], - ): string | undefined; - activate(source: NameSource): undefined | (() => void); - qualifier?( - source: NameSource, + ): ( pubkey: string, - candidates?: readonly string[], - displayFacts?: readonly NamingIdentity[], - ): string | undefined; + ) => { name: string; qualifier?: string | undefined } | undefined; + activate(source: NameSource): undefined | (() => void); subscribe?(listener: () => void): () => void; }; export type IdentityName = Readonly<{ @@ -56,6 +52,11 @@ export interface IdentityNameView { candidates?: readonly string[], displayFacts?: readonly NamingIdentity[], ): IdentityName | undefined; + /** Normalize one candidate scope once for many lookups. */ + scope( + candidates?: readonly string[], + displayFacts?: readonly NamingIdentity[], + ): (pubkey: string) => IdentityName | undefined; resolve( pubkey: string, fallback?: string, @@ -148,29 +149,37 @@ export function bindNames( const stops = [source.profiles.subscribe(emit)]; if (providers) stops.push(providers.subscribe(select)); select(); + const scope = ( + candidates?: readonly string[], + displayFacts?: readonly NamingIdentity[], + ) => { + let owner: NameProvider | undefined; + let local: ReturnType | undefined; + return (pubkey: string): IdentityName | undefined => { + if (closed) return undefined; + if (owner !== provider) { + owner = provider; + local = provider?.scope(source, candidates, displayFacts); + } + const label = local?.(pubkey); + if (label?.name) + return { + name: label.name, + qualifier: label.qualifier, + source: "agent-directory", + }; + const name = source.profiles.snapshot().get(pubkey.toLowerCase())?.name; + return name ? { name, source: "public-profile" } : undefined; + }; + }; const lookup = ( pubkey: string, candidates?: readonly string[], displayFacts?: readonly NamingIdentity[], - ): IdentityName | undefined => { - if (closed) return undefined; - const local = provider?.resolve(source, pubkey, candidates, displayFacts); - if (local) - return { - name: local, - qualifier: provider?.qualifier?.( - source, - pubkey, - candidates, - displayFacts, - ), - source: "agent-directory", - }; - const name = source.profiles.snapshot().get(pubkey.toLowerCase())?.name; - return name ? { name, source: "public-profile" } : undefined; - }; + ) => scope(candidates, displayFacts)(pubkey); return { lookup, + scope, resolve(pubkey, fallback, candidates, displayFacts) { return lookup(pubkey, candidates, displayFacts)?.name ?? fallback; }, diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 097ee716b..6bb901c64 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -2555,8 +2555,7 @@ it("uses the full channel choice set for one selected chip and follows membershi providers = [ { ...createAgentDirectory(), - resolve: () => "Alternative", - qualifier: () => undefined, + scope: () => () => ({ name: "Alternative" }), }, ]; policyChanged(); diff --git a/src/features/messages/mention-candidates.ts b/src/features/messages/mention-candidates.ts index d2a2af1e6..4c1cdcc81 100644 --- a/src/features/messages/mention-candidates.ts +++ b/src/features/messages/mention-candidates.ts @@ -26,6 +26,7 @@ export function mentionCandidates( .list() .channels.find((c) => c.id === channelId); const members = roster?.map((p) => p.pubkey) ?? channel?.members ?? []; + const memberKeys = new Set(members); const agents = session.agentChoices.snapshot().identities; const profiles = session.profiles.snapshot(); const known = knownAgentPubkeys(profiles, { @@ -66,7 +67,7 @@ export function mentionCandidates( ) .map((recipient) => ({ recipient, - member: members.includes(recipient.pubkey), + member: memberKeys.has(recipient.pubkey), agent: known.has(recipient.pubkey) || directory.some((p) => p.pubkey === recipient.pubkey && p.isAgent),