From a429d0b4a69a10ddb525ff71a0b2c439ec9f4e2e Mon Sep 17 00:00:00 2001 From: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> Date: Sat, 26 Sep 2026 07:49:45 -0700 Subject: [PATCH] perf(mentions): name each choice set once and skip closed choosers useMentionChoices resolved every row through names.resolve, which normalized the whole candidate list per row, and it built choices even while the picker was closed or disabled. - Replace the provider's resolve + qualifier pair with one scope(source, candidates, facts) call, and keep a small LRU of candidate scopes above a base layer keyed on the name sources, so scoped and unscoped lookups no longer evict each other. - Add names.scope() to the view; useMentionChoices resolves one scope per choice set instead of one normalization per row. - Return no candidates while the chooser is closed or disabled. - Use a Set for channel membership in mentionCandidates. Co-authored-by: Kalvin Chau Signed-off-by: Kalvin Chau Signed-off-by: peon <9ac6794b000690b7e814eb1805ad32405d0bec7d52838de3a86cf967565dacc0@buzz.block.builderlab.xyz> --- src/bundled/mentions/session-agents.test.tsx | 84 ++++++++ src/bundled/mentions/use-mention-choices.ts | 16 +- src/bundled/profiles/ProfilePanel.test.tsx | 2 +- src/features/identity-names/directory.test.ts | 103 +++++++-- src/features/identity-names/directory.ts | 202 ++++++++++-------- src/features/identity-names/service.test.ts | 6 +- src/features/identity-names/service.ts | 61 +++--- .../messages/MessageComposer.test.tsx | 3 +- src/features/messages/mention-candidates.ts | 3 +- 9 files changed, 333 insertions(+), 147 deletions(-) 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),