Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/bundled/mentions/MentionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
101 changes: 101 additions & 0 deletions src/bundled/mentions/mention-candidates.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, { name?: string; isAgent?: boolean }>([
[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<typeof mentionCandidates>[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<string | symbol>();
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),
);
});
145 changes: 129 additions & 16 deletions src/bundled/mentions/mention-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,14 @@ function availableMentionAgents<T extends { pubkey: string }>(
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. */
Expand All @@ -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<Agent, "pubkey" | "name" | "relay">;
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<string, Map<readonly DirectoryPerson[], Built>>
>();
/** Moves `key` to the newest end, dropping the oldest past `limit`. */
function recent<K, V>(map: Map<K, V>, 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<typeof buildCandidates>[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<Agent, "pubkey" | "name" | "relay">[] = [],
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]),
),
];
Comment thread
loganj marked this conversation as resolved.
const key = JSON.stringify([channelId, inviteAgents]);
const channels: Map<
string,
Map<readonly DirectoryPerson[], Built>
> = built.get(session) ?? new Map();
built.set(session, channels);
// Local rows, team rows and the current directory page.
const cache: Map<readonly DirectoryPerson[], Built> = 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<T extends DirectoryPerson>(
local: ReadonlyMap<string, MentionCandidate>,
directory: readonly T[],
profiles: ReadonlyMap<string, { name?: string | undefined }>,
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()
Expand Down Expand Up @@ -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<string, string>();
const directoryAgents = new Set<string>();
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<string, string>();
for (const person of roster ?? [])
if (!rostered.has(person.pubkey)) rostered.set(person.pubkey, person.name);
const agentNames = new Map<string, string[]>();
const managed = new Set<string>();
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) => ({
Expand All @@ -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),
),
Expand Down
71 changes: 70 additions & 1 deletion src/bundled/mentions/mention-ranking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]);
});
Loading
Loading