diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index e2035a2e18..fa40032b46 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -78,7 +78,8 @@ export function MentionPicker({ const parentAdmission = !!channel && channel.channelType !== "solo"; const members = draftRoster?.map((person) => person.pubkey) ?? channel?.members; - const memberKey = members?.join(":") ?? ""; + // Only an open chooser demands names; typing must not join every member. + const memberKey = open && !draftRoster ? (members?.join(":") ?? "") : ""; useEffect(() => { if (draftRoster || !open || !memberKey) return; let current = true; diff --git a/src/bundled/mentions/mention-candidates.test.ts b/src/bundled/mentions/mention-candidates.test.ts new file mode 100644 index 0000000000..f6ea94c11f --- /dev/null +++ b/src/bundled/mentions/mention-candidates.test.ts @@ -0,0 +1,101 @@ +import { expect, it } from "vitest"; +import { directoryChanges, mentionCandidates } from "./mention-candidates"; +import type { ComposerSession } from "../../features/messages/composer-session"; + +const key = (n: number) => n.toString(16).padStart(64, "0"); +const members = [1, 2, 3, 4, 5].map(key); +// 1: person; 2: unnamed; 3: agent member; 4: person; 5: archived. +const profiles = new Map([ + [key(1), { name: "Ada" }], + [key(3), { name: "Bot", isAgent: true }], + [key(4), { name: "Cy" }], + [key(5), { name: "Gone" }], +]); +const agents = [ + { pubkey: key(3), name: "Bot" }, + { pubkey: key(6), name: "Helper", managed: true }, +]; +const elsewhere = [ + { pubkey: key(7), name: "Far", relay: "wss://other.example" }, +]; + +// A new session each time, so no cached rows answer. +function rows( + directory?: Parameters[4], + session = fresh(), +) { + return [ + ...mentionCandidates(session, "c", false, undefined, directory, elsewhere), + ].sort((a, b) => a.recipient.pubkey.localeCompare(b.recipient.pubkey)); +} +function fresh() { + return { + viewer: key(1), + channels: { + list: () => ({ + status: "ready", + channels: [{ id: "c", name: "C", channelType: "stream", members }], + }), + }, + agentChoices: { snapshot: () => ({ identities: agents }) }, + profiles: { snapshot: () => profiles }, + archives: { + snapshot: () => ({}), + state: (pubkey: string) => + pubkey === key(5) ? "archived" : "not-archived", + }, + outbox: { supports: () => true }, + } as unknown as ComposerSession; +} + +it("reads only the session stores its cache compares", () => { + // mentionCandidates caches rows on these stores' snapshots (and the viewer, + // fixed per session). A new read here, including in the host's admission + // rules, must join that cache key, or rows go stale. + const read = new Set(); + const session = new Proxy(fresh(), { + get(target, key, receiver) { + read.add(key); + return Reflect.get(target, key, receiver); + }, + }); + rows([{ pubkey: key(8), name: "Dee" }], session); + expect([...read].sort()).toEqual([ + "agentChoices", + "archives", + "channels", + "outbox", + "profiles", + "viewer", + ]); +}); + +it("directory people that change nothing can be left out", () => { + const directory = [ + { pubkey: key(1), name: "Ada Lovelace" }, // named member: no change + { pubkey: key(3), name: "Bot", isAgent: true as const }, // agent member: no change + { pubkey: key(2), name: "Bea" }, // unnamed member: names its row + { pubkey: key(4), name: "Cy", isAgent: true as const }, // marks a member an agent + { pubkey: key(5), name: "Gone" }, // archived member + { pubkey: key(6), name: "Helper Two" }, // renames an outside agent + { pubkey: key(7), name: "Near" }, // takes another community's agent row + { pubkey: key(8), name: "Dee" }, // outsider + ]; + const local = new Map(rows().map((row) => [row.recipient.pubkey, row])); + const changes = directoryChanges( + local, + directory, + profiles, + (pubkey) => !!profiles.get(pubkey)?.name, + ); + expect(changes.map((person) => person.pubkey)).toEqual( + [2, 4, 5, 6, 7, 8].map(key), + ); + expect(rows(changes)).toEqual(rows(directory)); + // Each other kept person does change the rows. The archived member has no + // row to compare, so it is kept, which is safe. + for (const person of changes.filter((p) => p.pubkey !== key(5))) + expect(rows(changes.filter((other) => other !== person))).not.toEqual( + rows(changes), + ); +}); diff --git a/src/bundled/mentions/mention-candidates.ts b/src/bundled/mentions/mention-candidates.ts index 37f5295d7f..81a29da8ec 100644 --- a/src/bundled/mentions/mention-candidates.ts +++ b/src/bundled/mentions/mention-candidates.ts @@ -16,11 +16,14 @@ function availableMentionAgents( agents: readonly T[], canInvite: boolean | undefined, ) { - return channel?.members && - (channel.channelType === "stream" || channel.channelType === "forum") && - canInvite - ? agents.filter((agent) => !channel.members?.includes(agent.pubkey)) - : []; + if ( + !channel?.members || + (channel.channelType !== "stream" && channel.channelType !== "forum") || + !canInvite + ) + return []; + const members = new Set(channel.members); + return agents.filter((agent) => !members.has(agent.pubkey)); } /** Row detail for an outside choice. Nobody can be added to a DM. */ @@ -34,15 +37,110 @@ export function outsideMentionDetail( : "Not in channel · Choose whether to add when you send"; } +type DirectoryPerson = MentionRecipient & { isAgent?: true }; +type ElsewhereAgent = Pick; +const none: readonly never[] = []; +// Rows change only with their sources, not with each keystroke. Snapshots are +// immutable, so identity is their revision; `joinable()` copies on each call. +// Each directory array keeps its own rows: the chooser and its team rows pass +// different directories in one render and must not evict each other. Only the +// latest few channels and directory pages keep rows. +type Built = { sources: readonly unknown[]; rows: MentionCandidate[] }; +const built = new WeakMap< + ComposerSession, + Map> +>(); +/** Moves `key` to the newest end, dropping the oldest past `limit`. */ +function recent(map: Map, key: K, value: V, limit: number) { + map.delete(key); + map.set(key, value); + if (map.size > limit) map.delete(map.keys().next().value as K); + return value; +} +export type MentionCandidate = ReturnType[number]; + /** What the chooser offers. The host admits recipients itself (mention-admission). No reads or writes. */ export function mentionCandidates( session: ComposerSession, channelId: string, inviteAgents = false, roster?: readonly MentionRecipient[], - directory: readonly (MentionRecipient & { isAgent?: true })[] = [], + directory: readonly DirectoryPerson[] = none, /** The owner's agents in their other communities (`agents.joinable()`). */ - elsewhere: readonly Pick[] = [], + elsewhere: readonly ElsewhereAgent[] = none, +): readonly MentionCandidate[] { + // Every session store that buildCandidates and its admission rules read; a + // test fails when they read one more. The viewer is fixed per session. + const sources = [ + // Only this channel's row; activity elsewhere replaces the list, not it. + session.channels.list().channels.find((c) => c.id === channelId), + session.agentChoices.snapshot(), + session.profiles.snapshot(), + session.archives?.snapshot(), + session.outbox?.supports(9000), + roster, + JSON.stringify( + elsewhere.map(({ pubkey, name, relay }) => [pubkey, name, relay]), + ), + ]; + const key = JSON.stringify([channelId, inviteAgents]); + const channels: Map< + string, + Map + > = built.get(session) ?? new Map(); + built.set(session, channels); + // Local rows, team rows and the current directory page. + const cache: Map = recent( + channels, + key, + channels.get(key) ?? new Map(), + 4, + ); + const hit = cache.get(directory); + if (hit?.sources.every((source, i) => source === sources[i])) + return recent(cache, directory, hit, 3).rows; + const rows = buildCandidates( + session, + channelId, + inviteAgents, + roster, + directory, + elsewhere, + ); + recent(cache, directory, { sources, rows }, 3); + return rows; +} + +/** + * The directory people that change `mentionCandidates` or its labels, given + * its rows without a directory. A member a profile names gets that name, its + * aliases and, once naming knows the key, its label from the profile; the + * directory only adds its agent flag. Passing just these gives the same rows. + */ +export function directoryChanges( + local: ReadonlyMap, + directory: readonly T[], + profiles: ReadonlyMap, + named: (pubkey: string) => boolean, +) { + return directory.filter((person) => { + const row = local.get(person.pubkey); + return !( + row?.member && + profiles.get(person.pubkey)?.name && + named(person.pubkey) && + (!person.isAgent || row.agent) + ); + }); +} + +function buildCandidates( + session: ComposerSession, + channelId: string, + inviteAgents: boolean, + roster: readonly MentionRecipient[] | undefined, + directory: readonly DirectoryPerson[], + elsewhere: readonly ElsewhereAgent[], ) { const channel = session.channels .list() @@ -103,6 +201,25 @@ export function mentionCandidates( roster, new Set(hosts.keys()), ); + // Keyed lookups keep one pass over members linear in a large channel. + const listed = new Map(); + const directoryAgents = new Set(); + for (const person of directory) { + if (!listed.has(person.pubkey)) listed.set(person.pubkey, person.name); + if (person.isAgent) directoryAgents.add(person.pubkey); + } + const rostered = new Map(); + for (const person of roster ?? []) + if (!rostered.has(person.pubkey)) rostered.set(person.pubkey, person.name); + const agentNames = new Map(); + const managed = new Set(); + for (const agent of agents) { + agentNames.set(agent.pubkey, [ + ...(agentNames.get(agent.pubkey) ?? []), + agent.name, + ]); + if (agent.managed) managed.add(agent.pubkey); + } return [...choices.values()] .filter((p) => admits(p.pubkey)) .map((recipient) => ({ @@ -115,22 +232,18 @@ export function mentionCandidates( agent: hosts.has(recipient.pubkey) || known.has(recipient.pubkey) || - directory.some((p) => p.pubkey === recipient.pubkey && p.isAgent), + directoryAgents.has(recipient.pubkey), owned: !!session.viewer && profiles.get(recipient.pubkey)?.ownerPubkey === session.viewer, - managed: agents.some((a) => a.pubkey === recipient.pubkey && a.managed), + managed: managed.has(recipient.pubkey), aliases: [ ...new Set( [ profiles.get(recipient.pubkey)?.name || - directory.find((person) => person.pubkey === recipient.pubkey) - ?.name || - roster?.find((person) => person.pubkey === recipient.pubkey) - ?.name, - ...agents - .filter((a) => a.pubkey === recipient.pubkey) - .map((a) => a.name), + listed.get(recipient.pubkey) || + rostered.get(recipient.pubkey), + ...(agentNames.get(recipient.pubkey) ?? []), hosts.has(recipient.pubkey) ? recipient.name : undefined, ].filter((name): name is string => !!name), ), diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 2724fa3e94..992cf820fa 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -2,11 +2,12 @@ import { expect, it } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; import { exactMention, + matchingMentions, rankMentions, type MentionChoice, } from "./mention-ranking"; import { fixtureChoice, mentionConformance } from "./mention-rules.conformance"; -import { matchPerson } from "../../features/search/person-match"; +import { matchPerson, nameStarts } from "../../features/search/person-match"; const choice = ( key: string, name: string, @@ -159,3 +160,71 @@ it.each(mentionConformance.space)( ).toBe(fixture.expected); }, ); + +it("finds every ranked and Space choice through the name index", () => { + // The index is a prefilter: every portable fixture must rank, and choose on + // Space, exactly as it does when ranking reads the whole choice list. + for (const { query, choices } of [ + ...mentionConformance.ranking, + ...mentionConformance.space, + ]) { + const rows = choices.map(fixtureChoice); + const found = matchingMentions(rows, query); + expect(rankMentions(found, query)).toEqual(rankMentions(rows, query)); + expect(exactMention(found, query)).toBe(exactMention(rows, query)); + } + for (const { name, label, query, expected } of mentionConformance.matching) { + const row = choice("a", label, { label, aliases: [label] }); + if (expected !== null) + expect(matchingMentions([row], query), name).toEqual([row]); + } +}); + +it("returns index matches in list order and skips names that cannot match", () => { + const rows = [ + choice("1", "Mary-Jane Watson"), + choice("2", "Honey"), + choice("3", "Jane Doe"), + choice("4", "Agent", { label: "Agent · jane" }), + ]; + expect(matchingMentions(rows, "jane")).toEqual([rows[0], rows[2], rows[3]]); + expect(matchingMentions(rows, "oney")).toEqual([]); + expect(matchingMentions(rows, "jane d")).toEqual([rows[2]]); + expect(matchingMentions(rows, "")).toBe(rows); +}); + +it("bounds index work for long profile names and still matches them", () => { + // Profile names are member input of any length. + const words = Array.from({ length: 4000 }, (_, i) => `w${i}`).join(" "); + const long = `${"x".repeat(4000)}-tail`; + const starts = nameStarts(words); + expect(starts.complete).toBe(false); + expect(starts.starts).toHaveLength(16); + for (const name of [words, long]) + for (const start of nameStarts(name).starts) + expect([...start].length).toBeLessThanOrEqual(32); + const rows = [ + choice("1", words), + choice("2", long), + choice("3", "Wanda"), + choice("4", "Xavier"), + ]; + // Late words and queries longer than a key still rank as without the index. + for (const query of [ + "w3999", + "w39", + "tail", + "w", + "x".repeat(40), + `${"x".repeat(4000)}-t`, + "wanda", + ]) { + const found = matchingMentions(rows, query); + expect(rankMentions(found, query), query).toEqual( + rankMentions(rows, query), + ); + expect(exactMention(found, query), query).toBe(exactMention(rows, query)); + } + expect(matchingMentions(rows, "w3999")).toContain(rows[0]); + expect(matchingMentions(rows, "tail")).toContain(rows[1]); +}); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts index 7f2da2e827..1be5d180bd 100644 --- a/src/bundled/mentions/mention-ranking.ts +++ b/src/bundled/mentions/mention-ranking.ts @@ -1,7 +1,9 @@ import { exactName, matchPerson, + nameStarts, normalizeName, + startKey, } from "../../features/search/person-match"; import type { mentionCandidates } from "./mention-candidates"; export type MentionChoice = ReturnType[number] & { @@ -28,6 +30,59 @@ export function mentionMatch(choice: MentionChoice, query: string) { if (Number.isFinite(visible)) return visible; return 4 + baseMatch(choice, query); } +// One sorted index of name starts per immutable choice list. Choices with a +// name too long to index are always kept for ranking to match directly. +const indexes = new WeakMap< + readonly MentionChoice[], + { starts: string[]; owners: number[]; unindexed: number[] } +>(); +/** + * The choices, in their given order, with a label or alias that the query can + * match: a superset of every choice that `rankMentions` or `exactMention` keeps + * for this query. A binary search of the list's name index finds them, so a + * keystroke costs O(log n + matches) instead of a scan of every choice. + */ +export function matchingMentions( + choices: readonly MentionChoice[], + query: string, +): readonly MentionChoice[] { + const needle = startKey(normalized(query)); + if (!needle) return choices; + let index = indexes.get(choices); + if (!index) { + const entries: { start: string; owner: number }[] = []; + const unindexed: number[] = []; + choices.forEach((choice, owner) => { + const names = [...new Set([choice.label, ...choice.aliases])].map( + nameStarts, + ); + if (names.some((name) => !name.complete)) unindexed.push(owner); + else + for (const name of names) + for (const start of name.starts) entries.push({ start, owner }); + }); + entries.sort((a, b) => compare(a.start, b.start)); + index = { + starts: entries.map((entry) => entry.start), + owners: entries.map((entry) => entry.owner), + unindexed, + }; + indexes.set(choices, index); + } + let low = 0; + let high = index.starts.length; + while (low < high) { + const middle = (low + high) >> 1; + if ((index.starts[middle] ?? "") < needle) low = middle + 1; + else high = middle; + } + const found = new Set(index.unindexed); + for (let i = low; index.starts[i]?.startsWith(needle); i += 1) + found.add(index.owners[i] ?? 0); + return [...found] + .sort((a, b) => a - b) + .flatMap((owner) => choices[owner] ?? []); +} /** * Sorts by per-choice keys, so the order is transitive for any mix of people * and agents. Another community's agents follow everyone here. After match quality, your own agents come first. Agents that diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 0ca074e295..1e6ccb32ce 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, expect, it, vi } from "vitest"; import { MentionCompletion } from "./MentionCompletion"; import { MentionPicker } from "./MentionPicker"; import { mentionQuery } from "./mention-query"; +import { useMentionChoices } from "./use-mention-choices"; +import { useTeamMentions } from "./use-team-mentions"; import { createAgentLibrary } from "../../features/agents/library"; import { createAgentChoices } from "../../features/agents/choices"; import { ComposerCompletions } from "../../features/conversation/ComposerCompletions"; @@ -15,8 +17,11 @@ import type { } from "../../features/conversation/contracts"; import type { CompletionEditor } from "../../features/conversation/useCompletionEditor"; import type { ComposerInputElement } from "../../features/messages/composer-dom"; +import type { MentionRecipient } from "../../features/messages/mention-draft"; +import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; import type { RelaySession } from "../../features/relay/session"; import type { Contribution } from "../../plugins/contributions"; +import * as personMatch from "../../features/search/person-match"; // jsdom lacks scrollIntoView; the search highlight keeps its row in view. beforeEach(() => { @@ -27,6 +32,7 @@ beforeEach(() => { }); afterEach(() => { cleanup(); + vi.useRealTimers(); vi.unstubAllGlobals(); Reflect.deleteProperty(HTMLElement.prototype, "scrollIntoView"); vi.restoreAllMocks(); @@ -498,3 +504,133 @@ it("waits for the initial roster before installing team membership facts", async h.library.dispose(); } }); + +it("a closed chooser reads no member while its composer rerenders", async () => { + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + const h = fixture({ count: 300 }); + const members = new Set(h.people.map((person) => person.pubkey)); + const get = vi.spyOn(h.session.profiles.snapshot(), "get"); + const memberReads = () => + get.mock.calls.filter(([key]) => members.has(key)).length; + const picker = (select: () => boolean) => ( + true} + /> + ); + const view = render(picker(() => true)); + // Each keystroke rerenders the composer and its closed chooser. + for (let key = 0; key < 5; key++) view.rerender(picker(() => true)); + expect(memberReads()).toBe(0); + await userEvent.click( + screen.getByRole("button", { name: "Mention a member" }), + ); + expect(memberReads()).toBeGreaterThan(0); +}); + +it("an @ keystroke reuses the name index across draft and team rerenders", async () => { + const h = fixture({ count: 300 }); + const starts = vi.spyOn(personMatch, "nameStarts"); + function Chooser({ query }: { query: string }) { + const model = useMentionChoices(h.session, "parent", false, query, "open"); + // Shown team rows read candidates with their own directory. + useTeamMentions(h.session, "parent", false, query, model); + return null; + } + // The draft builds a new recipient array on every edit. + const draft = (query: string, selected: readonly MentionRecipient[]) => ( + + {/* Inline completion remounts on every keystroke. */} + + + ); + vi.useFakeTimers(); + // Each settled directory search finds nobody. + const settle = () => + act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + const view = render(draft("te", [])); + await settle(); + expect(h.session.directMessages.people).toHaveBeenCalled(); + expect(starts).toHaveBeenCalled(); + starts.mockClear(); + view.rerender(draft("tea", [])); + await settle(); + view.rerender(draft("team", [])); + await settle(); + expect(starts).not.toHaveBeenCalled(); + // A different selection is a different naming fact. + view.rerender(draft("team", [{ pubkey: "f".repeat(64), name: "Picked" }])); + expect(starts).toHaveBeenCalled(); + view.unmount(); + h.library.dispose(); +}); + +it("directory pages rebuild the name index only for people they add", async () => { + const h = fixture({ count: 300 }); + const profiles = h.session.profiles.snapshot(); + // Naming knows every profile, as the real service does. + Object.assign(h.session, { + names: { + subscribe: () => () => {}, + snapshot: () => 0, + scope: () => (key: string) => { + const name = profiles.get(key)?.name; + return name ? { name, source: "public-profile" } : undefined; + }, + }, + }); + const members = h.people.slice(1, 4); + const outsider = { pubkey: "e".repeat(64), name: "Teal Outsider" }; + let page = members; + // Every settled search returns a new array. + vi.mocked(h.session.directMessages.people).mockImplementation(async () => ({ + people: [...page], + hasMore: false, + })); + const starts = vi.spyOn(personMatch, "nameStarts"); + function Chooser({ query }: { query: string }) { + const model = useMentionChoices(h.session, "parent", false, query, "open"); + useTeamMentions(h.session, "parent", false, query, model); + return null; + } + vi.useFakeTimers(); + const settle = () => + act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + // Inline completion remounts on every keystroke. + const type = async (query: string) => { + view.rerender(); + await settle(); + }; + const view = render(); + await settle(); + starts.mockClear(); + // Pages of members change no row. + await type("pe"); + await type("per"); + expect(h.session.directMessages.people).toHaveBeenCalledTimes(3); + expect(starts).not.toHaveBeenCalled(); + // An outsider is a new choice set: one rebuild, then reused while it stays. + page = [...members, outsider]; + await type("pers"); + expect(starts).toHaveBeenCalledTimes(301); + starts.mockClear(); + await type("perso"); + await type("person"); + expect(starts).not.toHaveBeenCalled(); + view.unmount(); + h.library.dispose(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index 7a4578e2a9..e12392ae9b 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -15,14 +15,51 @@ import { DraftMentionRoster } from "../../features/messages/draft-mention-roster import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; import { archivedMention } from "../../features/messages/mention-admission"; import { mentionHistory } from "../../features/messages/mention-history"; -import { mentionCandidates } from "./mention-candidates"; +import { directoryChanges, mentionCandidates } from "./mention-candidates"; import { exactMention, + matchingMentions, rankMentions, type MentionChoice, } from "./mention-ranking"; type Person = ReturnType["people"][number]; const none: readonly Person[] = []; +// Derived once per cached candidate list (see mentionCandidates), so a +// keystroke reuses labels and keys instead of renaming every member. +const labeled = new WeakMap< + readonly unknown[], + { sources: readonly unknown[]; choices: readonly MentionChoice[] } +>(); +const keySets = new WeakMap>(); +const keyMaps = new WeakMap>(); +const byKey = ( + choices: readonly T[], +) => { + const cached = keyMaps.get(choices) as ReadonlyMap | undefined; + if (cached) return cached; + const map = new Map(choices.map((c) => [c.recipient.pubkey, c])); + keyMaps.set(choices, map); + return map; +}; +// Directory people that change the choice set, interned by content: each +// settled page and each carried merge is a new array with the same people. +// Their order changes no choice (ranking and naming sort by key), so it is +// not part of the content. +const pages = new WeakMap>(); +function page(session: ComposerSession, people: readonly Person[]) { + const key = JSON.stringify( + [...people] + .sort((a, b) => (a.pubkey < b.pubkey ? -1 : a.pubkey > b.pubkey ? 1 : 0)) + .map(({ pubkey, name, isAgent }) => [pubkey, name, !!isAgent]), + ); + const seen = pages.get(session) ?? new Map(); + pages.set(session, seen); + const kept = seen.get(key) ?? people; + seen.delete(key); + seen.set(key, kept); + if (seen.size > 8) seen.delete(seen.keys().next().value ?? ""); + return kept; +} /** * One installed key list per opening/query. Evidence stays live, order does not. * Local rows install first; directory rows only append after them. @@ -81,14 +118,44 @@ export function useMentionChoices( ]; }, [directory.people, carried]); const current = useCallback(() => { - const candidates = mentionCandidates( + const elsewhere = community?.joinable(); + const local = mentionCandidates( session, channelId, invite, roster, + undefined, + elsewhere, + ); + // Only directory people that change the choice set; a page of members rebuilds nothing. + const known = session.names?.scope(); + const changed = directoryChanges( + byKey(local), people, - community?.joinable(), + session.profiles.snapshot(), + (key) => !!known?.(key), ); + const outside = changed.length ? page(session, changed) : none; + const candidates = outside.length + ? mentionCandidates( + session, + channelId, + invite, + roster, + outside, + elsewhere, + ) + : local; + // Drafts recreate their recipient array on every edit; its names and keys + // are the naming fact, not its identity or positions. + const sources = [ + JSON.stringify(selected.map(({ pubkey, name }) => [pubkey, name])), + outside, + session.names?.snapshot(), + ]; + const hit = labeled.get(candidates); + if (hit?.sources.every((source, i) => source === sources[i])) + return hit.choices; const keys = [ ...new Set([ ...candidates.map((c) => c.recipient.pubkey), @@ -97,9 +164,8 @@ 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 + ...outside .filter((person) => !known?.(person.pubkey)) .map(({ pubkey, name, isAgent }) => ({ pubkey, @@ -110,7 +176,7 @@ export function useMentionChoices( ]; // One naming scope per choice set, not one normalization per row. const name = session.names?.scope(keys, facts); - return candidates.map((choice) => { + const choices: readonly MentionChoice[] = candidates.map((choice) => { const named = name?.(choice.recipient.pubkey); return { ...choice, @@ -118,22 +184,25 @@ export function useMentionChoices( qualifier: named?.qualifier, }; }); + labeled.set(candidates, { sources, choices }); + return choices; }, [session, channelId, invite, roster, selected, people, community]); // Keys known without the directory search: members and agent choices. - const localKeys = useCallback( - () => - new Set( - mentionCandidates( - session, - channelId, - invite, - roster, - undefined, - community?.joinable(), - ).map((c) => c.recipient.pubkey), - ), - [session, channelId, invite, roster, community], - ); + const localKeys = useCallback(() => { + const local = mentionCandidates( + session, + channelId, + invite, + roster, + undefined, + community?.joinable(), + ); + const cached = keySets.get(local); + if (cached) return cached; + const keys = new Set(local.map((c) => c.recipient.pubkey)); + keySets.set(local, keys); + return keys; + }, [session, channelId, invite, roster, community]); const { candidates, local } = useMemo(() => { // Source revisions invalidate the projection; selection reads them again synchronously. void profiles; @@ -143,7 +212,11 @@ export function useMentionChoices( void resolve; void revision; // A closed or disabled chooser shows nothing, so it names nothing. - if (!open) return { candidates: [], local: new Set() }; + if (!open) + return { + candidates: [] as readonly MentionChoice[], + local: new Set() as ReadonlySet, + }; return { candidates: current(), local: localKeys() }; }, [ open, @@ -166,7 +239,9 @@ export function useMentionChoices( (!roster && !!invite && agents.pending && agents.status !== "ready"); let rows = same ? installed.rows : undefined; if (!same && installed) install(undefined); - const rank = (choices: MentionChoice[]) => + // Only choices the query can match; ranking reads no others. + const matches = matchingMentions(candidates, query); + const rank = (choices: readonly MentionChoice[]) => rankMentions( choices, query, @@ -174,12 +249,13 @@ export function useMentionChoices( (key) => session.presence?.status(key) ?? "unknown", ); const outside = open - ? rank(candidates.filter((c) => !local.has(c.recipient.pubkey))) + ? rank(matches.filter((c) => !local.has(c.recipient.pubkey))) : []; - const missingNames = channel?.members?.some((key) => !profiles.has(key)); + const missingNames = + open && channel?.members?.some((key) => !profiles.has(key)); if (open && !rows && !membersPending) { const ranked = rank( - candidates.filter((c) => local.has(c.recipient.pubkey)), + matches.filter((c) => local.has(c.recipient.pubkey)), ).slice(0, 50); if ( ranked.length || @@ -199,7 +275,7 @@ export function useMentionChoices( // Append-only: late invited agents and directory pages never move shown rows. // Member-list changes keep their installed set until the query changes. const shown = new Set(rows.map((row) => row.recipient.pubkey)); - const added = (invite ? rank(candidates.filter((c) => c.agent)) : outside) + const added = (invite ? rank(matches.filter((c) => c.agent)) : outside) .filter((c) => !shown.has(c.recipient.pubkey)) .slice(0, 50 - rows.length); if (added.length) { @@ -225,15 +301,21 @@ export function useMentionChoices( // directory search runs, failed, or has more pages, a namesake may be missing. const complete = !directory.loading && !directory.error && !directory.more; return useMemo(() => { - const spaceMention = (choices: MentionChoice[], keys: Set) => - pending - ? undefined - : ((missingNames - ? undefined - : exactMention(choices, query, (c) => - keys.has(c.recipient.pubkey), - )) ?? (complete ? exactMention(choices, query) : undefined)); - const live = new Map(candidates.map((c) => [c.recipient.pubkey, c])); + const spaceMention = ( + all: readonly MentionChoice[], + keys: ReadonlySet, + ) => { + if (pending) return undefined; + const choices = matchingMentions(all, query); + return ( + (missingNames + ? undefined + : exactMention(choices, query, (c) => + keys.has(c.recipient.pubkey), + )) ?? (complete ? exactMention(choices, query) : undefined) + ); + }; + const live = byKey(candidates); const choices = (rows ?? []).map((row) => { const now = live.get(row.recipient.pubkey); const reason = now @@ -273,7 +355,8 @@ export function useMentionChoices( (!space || spaceMention(fresh, localKeys()) === pubkey) ); }, - truncated: rankMentions(candidates, query).length > 50, + truncated: + rankMentions(matchingMentions(candidates, query), query).length > 50, }; }, [ session, diff --git a/src/bundled/mentions/use-team-mentions.ts b/src/bundled/mentions/use-team-mentions.ts index f86c331454..288e19b13a 100644 --- a/src/bundled/mentions/use-team-mentions.ts +++ b/src/bundled/mentions/use-team-mentions.ts @@ -102,7 +102,8 @@ export function useTeamMentions( roster, session.agentChoices.snapshot().identities, ); - const current = candidates(); + // Only shown team rows read eligibility; a closed chooser builds none. + const current = open && rows?.length ? candidates() : []; const eligible = new Map(current.map((c) => [c.recipient.pubkey, c])); const choices = (open ? (rows ?? []) : []).map( ({ value, eventId, members }) => { diff --git a/src/bundled/mentions/useMentionDirectory.ts b/src/bundled/mentions/useMentionDirectory.ts index 8139a96411..76d24db243 100644 --- a/src/bundled/mentions/useMentionDirectory.ts +++ b/src/bundled/mentions/useMentionDirectory.ts @@ -148,7 +148,8 @@ export function useMentionDirectory( return useMemo( () => ({ /** The current query's page, or the last settled page while it loads. */ - people: (active && shown?.people) || empty, + // An empty page shares one array, so it changes no cached candidates. + people: active && shown?.people.length ? shown.people : empty, loading: active && !hit && !error, error, more: !!hit?.more, diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 7358287be4..2e11a08949 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -47,44 +47,63 @@ export function RichComposerInput({ channelId, (channel) => channel?.members, ); - useMentionArchives(session); + const archives = useMentionArchives(session); const roster = useContext(DraftMentionRoster); - const profiles = new Map(directory.profiles); - for (const recipient of draft.recipients) - profiles.set(recipient.pubkey, { - ...profiles.get(recipient.pubkey), - name: recipient.name, - }); const resolveName = useIdentityNames(session.names); - // Chips name selections among the people who read this destination. Draft - // recipients stay in even when archived, so their chips keep a name. - const candidates = [ - ...new Set([ - ...(roster?.map((p) => p.pubkey) ?? members ?? []).filter( - (pubkey) => !archivedMention(session, pubkey), - ), - ...draft.recipients.map((p) => p.pubkey), - ]), - ]; - const displayFacts = draft.recipients - .filter((recipient) => !session.names?.lookup(recipient.pubkey, candidates)) - .map((recipient) => ({ - pubkey: recipient.pubkey, - name: recipient.name, - })); - const qualifiers = new Map( - draft.recipients.map((recipient) => [ - recipient.pubkey, - session.names?.lookup(recipient.pubkey, candidates, displayFacts) - ?.qualifier, - ]), - ); - const labels = new Map( - draft.recipients.map((recipient) => [ - recipient.pubkey, - resolveName(recipient.pubkey, recipient.name, candidates, displayFacts), - ]), + // Recipients, not keystrokes, change chip names: typing prose must not + // copy every profile or scan every member of a large channel. + const recipientKey = JSON.stringify( + draft.recipients.map(({ pubkey, name }) => [pubkey, name]), ); + const { profiles, qualifiers, labels } = useMemo(() => { + void archives; + const recipients = JSON.parse(recipientKey) as [string, string][]; + const profiles = new Map(directory.profiles); + for (const [pubkey, name] of recipients) + profiles.set(pubkey, { ...profiles.get(pubkey), name }); + if (!recipients.length) + return { + profiles, + qualifiers: new Map(), + labels: new Map(), + }; + // Chips name selections among the people who read this destination. Draft + // recipients stay in even when archived, so their chips keep a name. + const candidates = [ + ...new Set([ + ...(roster?.map((p) => p.pubkey) ?? members ?? []).filter( + (pubkey) => !archivedMention(session, pubkey), + ), + ...recipients.map(([pubkey]) => pubkey), + ]), + ]; + const displayFacts = recipients + .filter(([pubkey]) => !session.names?.lookup(pubkey, candidates)) + .map(([pubkey, name]) => ({ pubkey, name })); + return { + profiles, + qualifiers: new Map( + recipients.map(([pubkey]) => [ + pubkey, + session.names?.lookup(pubkey, candidates, displayFacts)?.qualifier, + ]), + ), + labels: new Map( + recipients.map(([pubkey, name]) => [ + pubkey, + resolveName(pubkey, name, candidates, displayFacts), + ]), + ), + }; + }, [ + session, + directory.profiles, + recipientKey, + roster, + members, + archives, + resolveName, + ]); const previous = useRef(labels); const revealed = useRef( new Set([...qualifiers].filter(([, suffix]) => suffix).map(([key]) => key)), diff --git a/src/features/search/person-match.ts b/src/features/search/person-match.ts index 5f3b1aa216..73d22600ad 100644 --- a/src/features/search/person-match.ts +++ b/src/features/search/person-match.ts @@ -74,6 +74,39 @@ export function exactName( return exact.length === 1 ? exact[0] : undefined; } +// Bounds on one name's index entries. Names come from member profiles, so a +// many-word name must not grow the index with the square of its length. +const startLimit = 16; +const startLength = 32; + +/** + * Where `matchPerson` can begin a match in `name`: the folded, trimmed name + * itself and each later word start, each cut to its first 32 code points. A + * query matches only where one of these starts with the query's first 32 code + * points, so a sorted list of them finds matches by search. A name with more + * than 16 starts is not `complete`: callers must match it directly. + */ +export function nameStarts(name: string) { + const chars = [...name].flatMap((char) => [...foldPoint(char)]); + let start = 0; + let end = chars.length; + while (start < end && space.test(chars[start] ?? "")) start += 1; + while (end > start && space.test(chars[end - 1] ?? "")) end -= 1; + const key = (from: number) => + chars.slice(from, Math.min(end, from + startLength)).join(""); + const starts = [key(start)]; + for (let from = start + 1; from < end; from += 1) + if (isWord(chars[from]) && !isWord(chars[from - 1])) { + if (starts.length === startLimit) return { starts, complete: false }; + starts.push(key(from)); + } + return { starts, complete: true }; +} + +/** A query cut to the length of `nameStarts` entries. */ +export const startKey = (needle: string) => + [...needle].slice(0, startLength).join(""); + export type PersonMatch = { /** 0 equal, 1 name starts with the query, 2 a word equals it, 3 a word starts with it. */ tier: 0 | 1 | 2 | 3;