Skip to content
Open
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
6 changes: 5 additions & 1 deletion src/bundled/mentions/MentionCompletion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,12 @@ export function MentionCompletion({
retry: () => {
teams.retry();
model.directory.retry();
// Reread the agent library only when it failed: while a reload
// runs, shown rows that depend on it are disabled.
void session.agentChoices.refresh(
!!inviteAgents || teams.includeLegacy,
(model.includeLegacy &&
(agents.status === "error" || !!agents.error)) ||
teams.includeLegacy,
);
void session.archives?.refresh();
retry((value) => value + 1);
Expand Down
25 changes: 19 additions & 6 deletions src/bundled/mentions/MentionPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,21 @@ export function MentionPicker({
// Underline with the same rule that ranks mentions.
const matched = (label: string) =>
search.trim() ? matchPerson(label, search)?.positions : undefined;
// Agent rows may still arrive: the agent list is loading, or the chooser
// waits on a read of it (inviting, or the hold of README section 6) that
// the combined status misses once native inventory is ready. Members and a
// roster have their own status text.
const agentsLoading = agents.status === "loading" || model.agentsPending;
const agentsFailed = agents.status === "error" || !!agents.error;
// No results is only known once nothing is pending and the agent read has
// not failed: Retry may still bring rows.
const empty =
!!members &&
!model.pending &&
!agentsLoading &&
!agentsFailed &&
!candidates.length &&
!teams.choices.length;
return (
<PopoverRoot
open={open && !disabled}
Expand Down Expand Up @@ -333,15 +348,15 @@ export function MentionPicker({
</Button>
</>
)}
{agents.status === "loading" && !candidates.length && (
{agentsLoading && !candidates.length && (
<p role="status">Loading agents…</p>
)}
{(agents.status === "error" || !!agents.error) && (
{agentsFailed && (
<Button
type="button"
onClick={() =>
void session.agentChoices.refresh(
!!inviteAgents || teams.includeLegacy,
model.includeLegacy || teams.includeLegacy,
)
}
>
Expand Down Expand Up @@ -519,9 +534,7 @@ export function MentionPicker({
onClick={() => chooseTeam(team)}
/>
))}
{members && !candidates.length && !teams.choices.length && (
<p>No matching channel members.</p>
)}
{empty && <p>No matching channel members.</p>}
{audience?.named && (
<p className="text-caption text-subtle">
Your message tags{" "}
Expand Down
50 changes: 44 additions & 6 deletions src/bundled/mentions/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,11 @@ archived and not read-only, the choice set is:
1. the channel's members;
2. the agents that the client offers for mention in this channel;
3. the viewer's own agents from their other communities, which the client can
bring into this one (section 7); and
bring into this one (section 7), except one whose name equals, after
normalization (section 3), the name of one of the viewer's unarchived agents
in this community: that agent already has the name here. A query that is
the exact public key (Terms) of such an agent still includes it, because a
key has no namesakes (section 5); and
4. people and agents found by a community directory search for the current query.

Solo chats MUST NOT add directory people or agents from other communities. This
Expand Down Expand Up @@ -222,8 +226,8 @@ has no namesakes. Nor does a saved team with the same or a longer name keep it
from selecting. The steps below then apply as for a name.

The **local** choices are the ones known without the directory search: the
channel's members and the agents the client offers for mention (section 1, items
1 to 3, or a Solo chat's own agent list). The client selects:
channel's members and the agents the client offers for mention (section 1,
items 1 to 3, or a Solo chat's own agent list). The client selects:

1. the exact name among the local choices, once every member's name is known
or known to be absent; otherwise
Expand All @@ -234,6 +238,17 @@ channel's members and the agents the client offers for mention (section 1, items
In both cases the choice must be shown and can still be chosen. Check against
the whole set, not only the shown rows.

Both steps apply this precedence. When a local choice in this community
(section 1, item 1 or 2) has an alias or label equal to the query, the viewer's
agents from their other communities (item 3) cannot be the exact name.
Otherwise, when one of those agents has one, directory results cannot. So a
member named `Jitter` selects although the viewer's agent `Jitter` from
another community is also a choice. When nothing local in this community is
named `Jitter`, that agent selects at once: a directory result named `Jitter`
cannot make it ambiguous. In step 2, a local choice in this community and a
directory result with the same name are still namesakes. Every choice's longer
names still continue the query.

Local choices show before directory results (section 6), so a unique local name
selects at once: directory results cannot make it ambiguous, whether or not the
search has found them yet. A longer directory name still blocks Space once the
Expand Down Expand Up @@ -303,9 +318,32 @@ mention is never a recipient or a reference (section 8).
These rules keep a row from moving under the user's pointer or keyboard.

- Members and offered agents SHOULD show without waiting for the directory.
- The viewer's agents from their other communities (section 1, item 3) SHOULD
show only while the viewer's agents in this community are known from a
complete read, so that, once those are known, the name exception in section 1
does not disable a shown row. A failed or partial read is not evidence: it
may omit the agent here that has the name. Until such a read, a query that
matches (section 3) a name of one of the viewer's agents from other
communities holds them, and directory people with them, so those agents
still show before directory people. Its names are the ones its row has
without the directory search, including aliases cached in this community,
such as the profile name of an agent archived here; its exact public key
(Terms) holds it too, even when the name exception leaves it out of name
queries: only that read shows whether the key is one of the viewer's agents
here, which then has its row here. While the read runs, the agent list is
still loading (section 5), and a chooser SHOULD show that it is loading, not
that nothing matches, even when its agent list is otherwise ready; the same
holds while inviting agents waits for that list. Once it fails, the full
choice set stays unknown for that query: only the local step of section 5
applies, over the local choices the client has, a chooser SHOULD NOT say
that nothing matches, and a retry reads again. A client SHOULD start
loading the viewer's agents here when a composer that offers such agents
opens, so that this wait is rare.
- A shown row MUST NOT move while the query and the open chooser stay the same.
New rows are added only at the bottom. Directory people come after the rows
already shown.
already shown. Offered agents (section 1, item 2) that load after the list
is shown are added too, before the viewer's agents from other communities
and the directory people added with them.
- A row that becomes unavailable stays in place and is disabled. Enter and Tab
MUST NOT select it or fall through to Send.
- A client MAY show at most 50 rows and ask the user to type more.
Expand Down Expand Up @@ -398,14 +436,14 @@ each choice supplies its own label.
| --- | --- |
| `matching` | Section 3. `expected` is the tier of `label` for `query` (0 to 3), or `null` when it does not match. |
| `ranking` | Sections 3 and 4. `expected` is the full ordered key list. |
| `space` | Section 5 exact name among one complete choice set. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. |
| `space` | Section 5 step 2 over one complete choice set, with its precedence. Members, choices with `offered` (section 1, item 2) and choices with `elsewhere` (item 3) are local; other non-members are directory results. The local-first step is tested only in the desktop UI tests. `expected` is a key or `null`. |
| `query` | Section 2 syntax. `expected` is `{start, end, query}` or `null`. |
| `admission` | Section 2 multi-word rule. `expected` is a boolean. |
| `tags.write` | Section 8 writer. `expected` is the ordered `p` and `mention` tags, or an error class: `not_member`, `invalid`, or `too_many`. The sender is a member in addition to `members`. |
| `tags.read` | Section 8 reader. `expected` is `{mentions, references}`. |

Choice defaults: `label` is `name`, `aliases` is `[name]`, and `agent`, `owned`,
and `managed` are false. `elsewhere` (the host of another community) is absent. `history` maps a key to recency (higher is newer).
`managed` and `offered` are false. `elsewhere` (the host of another community) is absent. `history` maps a key to recency (higher is newer).
`presence` maps a key to `online` or `away`; a missing key is unknown.

Section 6 and the prompt in section 7 are interaction rules. Test them with
Expand Down
93 changes: 92 additions & 1 deletion src/bundled/mentions/mention-candidates.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { npubEncode } from "nostr-tools/nip19";
import { expect, it } from "vitest";
import { directoryChanges, mentionCandidates } from "./mention-candidates";
import {
directoryChanges,
elsewhereMatches,
mentionCandidates,
offeredElsewhere,
} from "./mention-candidates";
import type { ComposerSession } from "../../features/messages/composer-session";
import {
type NamePreference,
Expand Down Expand Up @@ -178,3 +184,88 @@ it("shows the verified name each row is not, and matches every verified name", (
setNamePreference("username");
}
});

it("holds an agent from another community for every name its row has without the directory", () => {
const far = key(7);
const session = fresh();
// Archived here under another name, it still takes its row from the other
// community, with the names cached here.
Object.assign(session, {
profiles: {
snapshot: () =>
new Map<string, unknown>([
...profiles,
[
far,
{
name: "Old",
verified: {
displayName: "farh",
username: "farh",
fullName: "Faraday",
},
},
],
]),
},
agentChoices: {
snapshot: () => ({
identities: [...agents, { pubkey: far, name: "Renamed" }],
}),
},
archives: {
snapshot: () => ({ status: "ready", archived: [key(5), far] }),
},
});
const row = mentionCandidates(
session,
"c",
false,
undefined,
undefined,
elsewhere,
).find((c) => c.recipient.pubkey === far);
expect(row?.elsewhere).toBe("other.example");
expect([...(row?.aliases ?? [])].sort()).toEqual(
["Far", "Faraday", "Old", "Renamed", "farh"].sort(),
);
for (const name of row?.aliases ?? [])
expect(
elsewhereMatches(session, "c", false, undefined, elsewhere, name),
).toBe(true);
// Its exact key names only it, as it does for Space.
for (const typed of [far, npubEncode(far), `nostr:${npubEncode(far)}`])
expect(
elsewhereMatches(session, "c", false, undefined, elsewhere, typed),
).toBe(true);
for (const typed of ["Ada", key(3), npubEncode(key(3))])
expect(
elsewhereMatches(session, "c", false, undefined, elsewhere, typed),
).toBe(false);
});

it("offers an agent from another community named like one of the owner's agents here only for its exact key, after a complete read", () => {
const namesake = {
pubkey: key(8),
name: " helper ",
relay: "wss://o.example",
};
const listed = [...elsewhere, namesake];
let complete = true;
const session = fresh();
Object.assign(session, {
agentChoices: { snapshot: () => ({ identities: agents, complete }) },
});
const offered = (query?: string) =>
offeredElsewhere(session, listed, query)?.map((agent) => agent.pubkey);
// "Helper" here already has the name.
for (const query of [undefined, "", "helper", "Helper", key(7)])
expect(offered(query)).toEqual([key(7)]);
// A key has no namesakes: it names only its owner.
const npub = npubEncode(key(8));
for (const typed of [key(8), key(8).toUpperCase(), npub, `nostr:${npub}`])
expect(offered(typed)).toEqual([key(7), key(8)]);
// A failed or partial read may omit the agent here that has its key.
complete = false;
expect(offered(key(8))).toEqual([]);
});
74 changes: 74 additions & 0 deletions src/bundled/mentions/mention-candidates.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
import { knownAgentPubkeys } from "../../features/agents/known";
import type { Agent } from "../../features/agents/service";
import { communityHost } from "../../features/messages/community-host";
import { matchPerson, normalizeName } from "../../features/search/person-match";
import { typedPublicKey } from "../../shared/identity/public-key";
import {
allowsOutsideMentions,
archivedMention,
Expand Down Expand Up @@ -71,6 +73,78 @@ type DirectoryPerson = MentionRecipient & {
};
type ElsewhereAgent = Pick<Agent, "pubkey" | "name" | "relay">;
const none: readonly never[] = [];

/**
* The owner's agents from their other communities that the chooser offers for
* `query` (README section 1): none named like one of the owner's unarchived
* agents here, which already has that name, except the one whose exact public
* key `query` is: a key names only its owner. Teams still reach them by key.
* None until a complete read lists the owner's agents here (`elsewhereMatches`):
* a failed or partial one may omit the agent here that has the name, or the
* key, which then takes its row here.
*/
export function offeredElsewhere(
session: ComposerSession,
elsewhere: readonly ElsewhereAgent[] | undefined,
query = "",
) {
if (!elsewhere?.length) return elsewhere;
const agents = session.agentChoices.snapshot();
if (!agents.complete) return none;
const here = new Set(
agents.identities
.filter((agent) => !archivedMention(session, agent.pubkey))
.map((agent) => normalizeName(agent.name)),
);
const key = typedPublicKey(query);
return elsewhere.filter(
(agent) => agent.pubkey === key || !here.has(normalizeName(agent.name)),
);
}

/**
* Whether the chooser would offer here one of the owner's agents from other
* communities that `query` matches (any, for no query). Their name rule needs
* the owner's agents here, so the chooser loads those and holds such agents
* until they are known (README section 6). Meanwhile, for a query they match,
* the agent list is loading: Space waits as for invited agents. After a failed
* read only local choices here can take Space. An agent matches by the names
* its row has without the directory (`buildCandidates` aliases), such as the
* name cached here for one archived here, or by its exact public key
* (`matchingMentions`), which no name matches.
*/
export function elsewhereMatches(
session: ComposerSession,
channelId: string,
inviteAgents: boolean | undefined,
roster: readonly MentionRecipient[] | undefined,
elsewhere: readonly ElsewhereAgent[] | undefined,
query = "",
) {
if (roster || inviteAgents || !elsewhere?.length) return false;
const channel = session.channels
.list()
.channels.find((c) => c.id === channelId);
const profiles = session.profiles.snapshot();
const here = session.agentChoices.snapshot().identities;
const key = typedPublicKey(query);
return availableMentionAgents(
channel,
elsewhere,
session.outbox?.supports(9000),
).some((agent) => {
if (key !== undefined) return agent.pubkey === key;
const profile = profiles.get(agent.pubkey);
return [
agent.name,
profile?.name,
profile?.verified?.fullName,
profile?.verified?.username,
...here.flatMap((a) => (a.pubkey === agent.pubkey ? [a.name] : [])),
].some((name) => name !== undefined && !!matchPerson(name, query));
Comment thread
matt2e marked this conversation as resolved.
Resolved
});
}
Comment thread
matt2e marked this conversation as resolved.
Resolved

// 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
Expand Down
16 changes: 15 additions & 1 deletion src/bundled/mentions/mention-ranking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
type MentionChoice,
} from "./mention-ranking";
import { fixtureChoice, mentionConformance } from "./mention-rules.conformance";
import { spaceMention } from "./use-mention-choices";
import { matchPerson, nameStarts } from "../../features/search/person-match";
const choice = (
key: string,
Expand Down Expand Up @@ -246,8 +247,21 @@ it.each(mentionConformance.ranking)(
it.each(mentionConformance.space)(
"conforms to the portable Space contract: $name",
(fixture) => {
// Step 2 of section 5 over the complete choice set: members, offered
// agents and agents from other communities are local; other choices are
// directory results.
const choices = fixture.choices.map(fixtureChoice);
const local = new Set(
fixture.choices
.filter((c) => c.member || c.offered || c.elsewhere)
.map((c) => c.pubkey),
);
expect(
exactMention(fixture.choices.map(fixtureChoice), fixture.query) ?? null,
spaceMention(choices, local, fixture.query, {
pending: false,
missingNames: true,
complete: true,
}) ?? null,
).toBe(fixture.expected);
},
);
Expand Down
9 changes: 9 additions & 0 deletions src/bundled/mentions/mention-ranking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,15 @@ export const continuedMention = (
choices: readonly MentionChoice[],
query: string,
) => continuingNames(choices, query).length > 0;
/** Whether one of the choice's known names equals the query (README section 5). */
export function namesQuery(choice: MentionChoice, query: string) {
const needle = normalized(query);
return (
!!needle &&
choice.aliases.length > 0 &&
choiceNames(choice).some((name) => normalized(name) === needle)
);
}
/**
* Space is intent only for one exact key across the full, uncapped choice set.
* Every choice can continue the name (`continuedMention`); only choices
Expand Down
Loading
Loading