diff --git a/docs/agents.md b/docs/agents.md index d0f4a6cfeb..2c39352a85 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -401,7 +401,23 @@ chooser is unavailable because activation failed. The picker shows keys alongside names (namesakes remain separate), reads optional profiles only on demand, and keeps selected identity spans in scoped drafts. Typing a name alone does not notify anyone. Plain Space after a unique exact name -selects its recipient; ambiguous names require Tab, Enter or click. Editing a +selects its recipient; ambiguous names require Tab, Enter or click. While the +chooser is still waiting on members, names, agents or a directory search, Space +holds the name as a pending chip (a spinner in place of its `@`) and applies the +same rule once the evidence arrives, within 8 s, or returns it to prose; sending +waits for a pending chip, and when one it waited for returns to prose it keeps +the draft and says which name was not tagged and why (not confirmed as one +person or agent, not taggable here, the recipient limit or the message +length), so the next send sends the text knowingly. A name already known to +continue the query (`Mira Example` for `mira`) is a declined Space, not a +pending one, so the next word +can still complete it; a longer name learned only after the chip was held +extends the chip over the words typed after it when they spell one of those +names up to a name boundary, the longest they complete (`@mira Example hi` +tags `Mira Example`), and otherwise returns it to prose, where the reopened +chooser answers the rest of the name from the page already read for the chip +(a complete directory page settles every query extending its own, with no +further read), so the Space that completes the name selects it. Editing a selected span removes its notification intent. Native beforeinput ranges preserve untouched spans; missing range evidence, IME/history edits and collapsed deletions clear selections rather than guess. Even a same-text replacement drops the edited identity. Selected mentions appear as inline identity chips in the composer. Namesakes @@ -479,10 +495,17 @@ recipients among the destination's members plus the draft recipients. Plain Space selects only a unique exact name/alias/label across the full uncapped candidate set, and only if that identity is displayed and still eligible. A known -longer name beginning with that name plus a space prevents selection. Partial -names, ambiguous names, modified Space, IME composition, code and protected literal -ranges keep ordinary editing behavior. Selection rechecks available evidence and -stores only `{pubkey, name}`; qualifiers are presentation, not wire data. +longer name beginning with that name plus a space prevents selection, unless it is +another name of that same identity. Partial names, ambiguous names, modified Space, +IME composition, code and protected literal ranges keep ordinary editing behavior. Selection rechecks available evidence and +stores only `{pubkey, name}`; qualifiers are presentation, not wire data. A Space +the chooser cannot decide yet, because its evidence is still loading, holds the +name as a pending chip that the plugin's headless resolver settles by the same +rule (`resolve-space-mention.ts`); a known longer name declines Space outright +rather than holding it, while one found after the chip was held extends the +chip over the typed words that complete it or returns it to prose; the host +admits the result like any selection, and a pending chip is never stored in a +draft or sent as a tag. The composer rejects already-known archived recipients (never the viewer) at send entry. The next draft keeps only agents that the sent message notified. This is not an archive transaction: archive diff --git a/src/bundled/composer/lab/fixture.tsx b/src/bundled/composer/lab/fixture.tsx index 9381cea212..00a1d6fd32 100644 --- a/src/bundled/composer/lab/fixture.tsx +++ b/src/bundled/composer/lab/fixture.tsx @@ -57,6 +57,18 @@ const rawSession = { subscribe: empty, ensure: async () => undefined, }, + directMessages: { + // The community directory knows the same four profiles, so a picker + // search settles (and is cached) instead of failing. + people: async (query: string) => ({ + people: [...profiles] + .filter(([, { name }]) => + name.toLowerCase().startsWith(query.trim().toLowerCase()), + ) + .map(([pubkey, profile]) => ({ pubkey, ...profile })), + hasMore: false, + }), + }, channels: { list: () => channelList, subscribeList: empty, diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index 6f6828b86b..2e0821dc2d 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -1,12 +1,17 @@ import { TeamMentionAvatars } from "./TeamMentionAvatars"; -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import { useTeamMentions } from "./use-team-mentions"; import { useMentionChoices } from "./use-mention-choices"; import type { ComposerCompletionProps } from "../../features/conversation/contracts"; import type { ComposerSession } from "../../features/messages/composer-session"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { outsideMentionDetail } from "./mention-candidates"; -import { matchesMentionQuery } from "./mention-query"; +import { matchesMentionQuery, pendingMentionQuery } from "./mention-query"; +import { continuedMention } from "./mention-ranking"; +import { resolveSpaceMention } from "./resolve-space-mention"; +import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; +import { DraftMentionRoster } from "../../features/messages/draft-mention-roster"; +import { AgentsContext } from "../../features/agents/react"; import { avatarMedia } from "../../shared/avatar-source"; // Demand bookkeeping only, not another profile cache. Missing names do not issue @@ -30,14 +35,7 @@ export function MentionCompletion({ query.query, JSON.stringify(["inline", scope, channelId, threadRootId, query.start]), ); - const { - profiles, - agents, - channel, - list, - choices, - roster: draftRoster, - } = model; + const { profiles, agents, channel, list, choices } = model; const teams = useTeamMentions( session, channelId, @@ -45,6 +43,10 @@ export function MentionCompletion({ query.query, model, ); + const selected = useContext(SelectedMentionContext); + const roster = useContext(DraftMentionRoster); + const community = useContext(AgentsContext); + const draftRoster = roster?.recipients; const members = draftRoster?.map((p) => p.pubkey) ?? channel?.members ?? []; const memberKey = members.join(":"); const parentAdmission = !!channel && channel.channelType !== "solo"; @@ -182,8 +184,44 @@ export function MentionCompletion({ const next = [...ids, ...additions.map((item) => item.id)].slice(0, 50); shown.current = { session, key, ids: next }; const byId = new Map(available.map((item) => [item.id, item])); + // Space is undecided, not declined, only while evidence is still arriving: + // members, names or agents loading, or the directory search waiting or + // running. Then the host may hold the name and let the resolver apply + // section 5 once the choice set is known. A name already known to continue + // the query declines Space whatever else arrives: the next word may + // complete that longer name, so Space stays prose and the query open. The + // known set is every candidate the chooser has, not only the shown rows: + // members or roster, offered agents and the directory rows received so + // far, the same set `spaceId` is decided over. Anything else is prose. + const undecided = + admitted && + model.spaceId === undefined && + !teams.blocksSpace && + !continuedMention(model.candidates, query.query) && + (model.pending || model.directory.loading) && + pendingMentionQuery(query.query); const withdraw = publish({ spaceId: teams.blocksSpace ? undefined : model.spaceId, + ...(undecided + ? { + spacePending: { + name: query.query, + resolve: (signal: AbortSignal) => + resolveSpaceMention( + session, + { + channelId, + inviteAgents, + roster, + selected, + elsewhere: community?.joinable(), + }, + query.query, + signal, + ), + }, + } + : {}), items: next.flatMap((id) => byId.get(id) ?? []), ...(model.pending ? { status: "Loading recipients…" } @@ -260,8 +298,11 @@ export function MentionCompletion({ choices, channel, draftRoster, + roster, inviteAgents, parentAdmission, + selected, + community, ]); return null; } diff --git a/src/bundled/mentions/README.md b/src/bundled/mentions/README.md index 0f2c971a6e..f7edccb075 100644 --- a/src/bundled/mentions/README.md +++ b/src/bundled/mentions/README.md @@ -110,9 +110,11 @@ did not fail. While the search runs, the chooser shows no rows for that query. If the search fails, the chooser keeps its error and a way to retry. The desktop UI test "a failed multi-word directory search keeps its error and retry" in [solo-agents.test.tsx](solo-agents.test.tsx) covers this case: -`@Mary J` fails, then Retry finds Mary Jane. The one case where the client MAY -skip the search is the refuted prefix in section 6. An `@` inside a mention -that the user already selected does not open a query. +`@Mary J` fails, then Retry finds Mary Jane. The client MAY skip the search +only for the refuted prefix in section 6, or when a complete finished search +for a prefix of the query already answers it (section 6). An `@` inside a mention +that the user already selected, or that is pending (section 5), including one +that has extended over the words typed after it, does not open a query. ## 3. Matching (normative) @@ -203,14 +205,17 @@ set of choices when all of these are true: (`Jose Luis` and `Jose-Luis` continue `jose`; `Josefa` does not). A label's key qualifier or generated `(agent)` suffix only tells namesakes apart, so `Honey · 4prr` and a generated `Honey (agent)` do not continue `honey`. - A literal name `Honey (agent)` still continues `honey`. + A literal name `Honey (agent)` still continues `honey`. A choice's own longer + name does not continue the query when that same choice also has an alias or + label equal to it: an agent known as both `Example` and `Example Agent` is one + identity, not a namesake of itself, so `example` selects it. 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: -1. the exact name among the local choices, once every member's name is known; - otherwise +1. the exact name among the local choices, once every member's name is known + or known to be absent; otherwise 2. the exact name among the full choice set, once the full choice set is known: no directory search for this query is waiting, running, or failed, and the search reported no more pages. @@ -222,10 +227,52 @@ 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 search reports it, but not before. A member whose name is not known yet may be a -namesake, so until it is, only step 2 applies. A name that only a directory -result has could still gain a namesake from a pending search, so Space MUST NOT -select it before the search finishes. Otherwise Space is a normal space. Tab, -Enter, and a click select the highlighted row. +namesake, so until it is, only step 2 applies. A member whose profile read +completed without finding a profile has no name, so it cannot be a namesake and +does not hold step 1 back; a failed read is not evidence. A name that only a +directory result has could still gain a namesake from a pending search, so Space +MUST NOT select it before the search finishes. Otherwise Space is a normal +space. Tab, Enter, and a click select the highlighted row. + +While the client cannot yet decide — the member list, member names or agent +list is still loading, or a directory search for the query is waiting or +running — it MAY instead mark the query as a **pending mention**: a chip with +no recipient, shown as still resolving. When the full choice set is known it +applies this section to the pending name as if Space had been typed then: a +selection becomes the recipient (checked for admission as any selection is); +anything else, including a failed or truncated search or a bounded wait +expiring (desktop: 8 s), returns the text to prose. A name the client has +already declined is not pending: a trailing space or punctuation, a multi-word +query that continues no name, a saved team with the same or a longer name, or +a known choice whose name continues the query (a loaded member `Mira Example` +for `mira`) keeps Space a normal space. Such a longer name blocks whatever +else arrives, and the next word may complete it (`@mira Example ` selects the +member), which a chip that ended the query could not. A pending name that, +once the full choice set is known, only longer known names keep from selecting +(`Mira Example`, found by the directory search for `mira` after the chip was +held) MAY instead extend over the words typed after the chip when they spell +one of those names up to a name boundary (`@mira Example hi` becomes a pending +`@mira Example` with ` hi` outside it), taking the longest name the words +complete, as Space would have needed all of it typed; this section then applies +to the longer name as if Space had been typed after it, under the same bound. +Words that stop mid-name or complete none of those names return the text to +prose. A pending mention, extended or not, MUST end at a name boundary: a name +character typed right after it (`@honey`, Space, Backspace, `suckle`) returns +it to the typed query, as an edit does to a recipient. The chooser then reopens over the typed name, and the complete search +for the pending name already settles the directory for every query extending +it (section 6), so no search is waiting and the Space that completes the +longer name selects it: `@mira Exam` returned to prose, then `ple ` selects +`Mira Example`. In a +DM a pending name that resolves to someone outside the DM returns to prose, +since the chooser's "Will not be notified" row was never shown. Sending while +a mention is pending waits for it, through such an extension; if a mention the +send waited for returns to prose, the client MUST NOT send in that same action, +since the user saw the name held and would notify no one: it keeps the draft, +says which name was not tagged and why (it could not be confirmed as one +person or agent, because nothing matched, several did or the search did not +finish; or the match could not be tagged here, would exceed the recipient limit +or would make the message too long), and the next send sends the text. A pending +mention is never a recipient or a reference (section 8). ## 6. List stability (desktop behavior; not in the fixtures) @@ -243,6 +290,14 @@ These rules keep a row from moving under the user's pointer or keyboard. last finished search. It MAY cache finished searches for the session. It MUST NOT cache failures or searches that find no one. A new opening of the chooser searches again for such a query. +- A finished search that reports no more pages holds every match for a longer + query that starts with its query, because the relay matches names by prefix. + A client MAY answer such a longer query from that page, filtered by section 3, + with no search and no typing pause; the directory is then settled for it + (section 5). A search that reported more pages, or failed, answers nothing + beyond its own query; the listing shown for an empty query is not a name + search and answers no query but its own; and an exact 64-hex key is never + answered this way. A retry searches afresh. - A search refutes its query when it finds no one, reports no more pages, and the query contains a letter or digit. After that, a client MAY skip the directory search for a longer query that starts with the refuted query, and @@ -348,6 +403,9 @@ bin/pnpm exec vitest run src/bundled/mentions/mention-ranking.test.ts \ and [use-mention-choices.ts](use-mention-choices.ts). Directory pages come from [useMentionDirectory.ts](useMentionDirectory.ts). - Matching, order, and Space: [mention-ranking.ts](mention-ranking.ts). + A pending mention is decided by [resolve-space-mention.ts](resolve-space-mention.ts); + the host holds and settles the chip in + [MessageComposer.tsx](../../features/messages/MessageComposer.tsx). - Query syntax: [mention-query.ts](mention-query.ts). - Recipient admission (host): [mention-admission.ts](../../features/messages/mention-admission.ts). Explicit-choice recency (host): [mention-history.ts](../../features/messages/mention-history.ts). diff --git a/src/bundled/mentions/mention-query.ts b/src/bundled/mentions/mention-query.ts index 2734fcc34e..e961c9eecb 100644 --- a/src/bundled/mentions/mention-query.ts +++ b/src/bundled/mentions/mention-query.ts @@ -19,6 +19,12 @@ export function mentionQuery(text: string, caret: number) { return { start, end: caret, query }; } +/** A query Space may hold as a pending mention: a name still being typed. + * Trailing space or punctuation says the user has moved on to prose. */ +export function pendingMentionQuery(query: string) { + return !!normalizeName(query) && !/[\s.,!?;:)\]}]$/u.test(query); +} + export function matchesMentionQuery(query: string, names: readonly string[]) { if (!query.includes(" ")) return true; const lower = foldName(query); diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts index 992cf820fa..627a63d5ce 100644 --- a/src/bundled/mentions/mention-ranking.test.ts +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -1,6 +1,8 @@ import { expect, it } from "vitest"; import { npubEncode } from "nostr-tools/nip19"; import { + continuedMention, + continuingNames, exactMention, matchingMentions, rankMentions, @@ -126,6 +128,38 @@ it("orders mixed people and agents the same way from every input order", () => { ]); }); +it("a known longer name continues the query, except an identity's own beside its exact name", () => { + const longer = choice("1", "Mira Example"); + expect(continuedMention([longer], "mira")).toBe(true); + expect(continuedMention([longer], "Mira Example")).toBe(false); + // The continuing names, each once, for a pending mention to extend over. + expect( + continuingNames( + [longer, choice("5", "Mira Example"), choice("6", "Mira Example Two")], + "mira", + ), + ).toEqual(["Mira Example", "Mira Example Two"]); + expect(continuingNames([longer], "Mira Example")).toEqual([]); + // Section 5's exception: `Example Agent` is another name of the `Example` + // agent, not a namesake, so it neither continues nor blocks `example`. + const self = choice("2", "Example", { + agent: true, + aliases: ["Example Agent", "Example"], + label: "Example Agent", + }); + expect(continuedMention([self], "example")).toBe(false); + expect(exactMention([self], "example")).toBe(self.recipient.pubkey); + // A key qualifier or generated `(agent)` suffix is not a longer name. + expect( + continuedMention( + [ + choice("3", "Mira", { label: "Mira · 4prr", qualifier: "4prr" }), + choice("4", "Mira", { agent: true, label: "Mira (agent)" }), + ], + "mira", + ), + ).toBe(false); +}); it("validates the portable mention fixture version and case lists", () => { expect(mentionConformance.version).toBe(2); expect(mentionConformance.matching.length).toBeGreaterThan(0); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts index 1be5d180bd..e71113fe6a 100644 --- a/src/bundled/mentions/mention-ranking.ts +++ b/src/bundled/mentions/mention-ranking.ts @@ -1,5 +1,6 @@ import { exactName, + extendsName, matchPerson, nameStarts, normalizeName, @@ -137,31 +138,61 @@ export function rankMentions( ) .map((k) => k.c); } +const choiceNames = (c: MentionChoice) => [...c.aliases, c.label]; +/** + * The known names that continue the query (README section 5), each once, + * so no exact name can be selected while any exists. A key qualifier or + * generated `(agent)` suffix only tells namesakes apart, so it never + * continues the name. Nor does a choice's own longer name when that same + * choice also has the exact name: one identity is not its own namesake. + * Choices only arrive, never leave, so once a name continues the query + * nothing that loads later can lift it: Space is declined, not pending. + */ +export function continuingNames( + choices: readonly MentionChoice[], + query: string, +) { + const needle = normalized(query); + const names = new Map(); + for (const c of choices) { + // `Example Agent` never keeps `example` from selecting the agent that is + // also named `Example`. + if (choiceNames(c).some((name) => normalized(name) === needle)) continue; + const label = + c.qualifier && c.label.endsWith(` · ${c.qualifier}`) + ? c.label.slice(0, -(c.qualifier.length + 3)) + : c.label; + // Literal names still block through aliases; generated suffixes do not. + const longer = + c.agent && c.aliases.some((name) => `${name.trim()} (agent)` === label) + ? c.aliases + : [...c.aliases, label]; + for (const name of longer) + if (extendsName(name, needle) && !names.has(normalized(name))) + names.set(normalized(name), name.trim()); + } + return [...names.values()]; +} +export const continuedMention = ( + choices: readonly MentionChoice[], + query: string, +) => continuingNames(choices, query).length > 0; /** * Space is intent only for one exact key across the full, uncapped choice set. - * Every choice can continue the name; only choices `among` can equal it. A key - * qualifier or generated `(agent)` suffix only tells namesakes apart, so it - * never continues the name. + * Every choice can continue the name (`continuedMention`); only choices + * `among` can equal it. */ export const exactMention = ( choices: readonly MentionChoice[], query: string, among: (choice: MentionChoice) => boolean = () => true, ) => - exactName( - choices, - (c) => [...c.aliases, c.label], - query, - (c) => c.aliases.length > 0 && among(c), - (c) => { - const label = - c.qualifier && c.label.endsWith(` · ${c.qualifier}`) - ? c.label.slice(0, -(c.qualifier.length + 3)) - : c.label; - // Literal names still block through aliases; generated suffixes do not. - return c.agent && - c.aliases.some((name) => `${name.trim()} (agent)` === label) - ? c.aliases - : [...c.aliases, label]; - }, - )?.recipient.pubkey; + continuedMention(choices, query) + ? undefined + : exactName( + choices, + choiceNames, + query, + (c) => c.aliases.length > 0 && among(c), + () => [], + )?.recipient.pubkey; diff --git a/src/bundled/mentions/mention-rules.fixtures.json b/src/bundled/mentions/mention-rules.fixtures.json index 4ed34c81b4..ef03f03c90 100644 --- a/src/bundled/mentions/mention-rules.fixtures.json +++ b/src/bundled/mentions/mention-rules.fixtures.json @@ -1199,6 +1199,26 @@ } ], "expected": null + }, + { + "name": "alias_of_same_identity_does_not_block", + "query": "example", + "choices": [ + { + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "name": "Example", + "aliases": ["Example Agent", "Example"], + "member": false, + "agent": true, + "owned": true + }, + { + "pubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "name": "Member", + "member": true + } + ], + "expected": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" } ], "query": [ diff --git a/src/bundled/mentions/resolve-space-mention.ts b/src/bundled/mentions/resolve-space-mention.ts new file mode 100644 index 0000000000..2fe16f42b3 --- /dev/null +++ b/src/bundled/mentions/resolve-space-mention.ts @@ -0,0 +1,236 @@ +import { + fullSession, + type ComposerSession, +} from "../../features/messages/composer-session"; +import { allowsOutsideMentions } from "../../features/messages/mention-admission"; +import type { MentionRecipient } from "../../features/messages/mention-draft"; +import type { MentionRoster } from "../../features/messages/draft-mention-roster"; +import type { Agent } from "../../features/agents/service"; +import { mentionCandidates } from "./mention-candidates"; +import { continuingNames } from "./mention-ranking"; +import { namedCandidates, spaceMention } from "./use-mention-choices"; +import { teamBlocksSpace } from "./use-team-mentions"; +import { MENTION_RESOLVE_TIMEOUT_MS } from "../../features/conversation/completion"; +import { + fetchMentionDirectoryPage, + type MentionDirectoryPage, +} from "./useMentionDirectory"; + +/** What the chooser knew about its destination when Space was typed. */ +export type SpaceMentionScope = { + channelId: string; + inviteAgents?: boolean | undefined; + roster?: MentionRoster | undefined; + /** Recipients already in the draft take part in naming. */ + selected: readonly MentionRecipient[]; + /** The owner's agents in their other communities, as the chooser offers them. */ + elsewhere?: readonly Pick[] | undefined; +}; + +/** + * What Space would have done with `name` had the chooser's evidence been + * complete (README section 5): the recipient it selects; null to return the + * text to prose; or, when only longer known names continue the name over the + * complete choice set, those names, each resolvable under the same rule + * (`continued`). The words typed after the chip may complete one of them, and + * the host asks that one. A truncated page, a namesake, a team block, a DM + * outsider, any failure, the caller's abort or the timeout is null. + */ +export type SpaceMentionResolution = + | MentionRecipient + | { continued: readonly ContinuedSpaceMention[] } + | null; +export type ContinuedSpaceMention = { + name: string; + resolve(signal: AbortSignal): Promise; +}; + +/** + * Headless: the chooser that published the pending name unmounted when the + * chip replaced the typed text. + */ +export async function resolveSpaceMention( + session: ComposerSession, + scope: SpaceMentionScope, + name: string, + signal: AbortSignal, +): Promise { + if (signal.aborted) return null; + const controller = new AbortController(); + const abort = () => controller.abort(); + const timer = setTimeout(abort, MENTION_RESOLVE_TIMEOUT_MS); + signal.addEventListener("abort", abort, { once: true }); + try { + // The bound holds even over a read that ignores its signal. + return await Promise.race([ + resolve(session, scope, name, controller.signal), + new Promise((_, reject) => { + controller.signal.addEventListener("abort", () => reject(), { + once: true, + }); + }), + ]); + } catch { + return null; + } finally { + clearTimeout(timer); + signal.removeEventListener("abort", abort); + } +} + +async function resolve( + session: ComposerSession, + { + channelId, + inviteAgents, + roster: draft, + selected, + elsewhere, + }: SpaceMentionScope, + name: string, + signal: AbortSignal, +): Promise { + // A roster's owner says when its agents have loaded, as the inventory + // wait below does for a channel. + const roster = draft?.pending ? await draft.pending : draft?.recipients; + const invite = !roster && !!inviteAgents; + if (!roster) { + session.channels.ensureList(); + await settled( + session.channels.subscribeList, + session.channels.list, + (list) => list.status === "ready" || list.status === "error", + signal, + ); + } + const channel = session.channels + .list() + .channels.find((c) => c.id === channelId); + const members = roster?.map((p) => p.pubkey) ?? channel?.members ?? []; + // A member a completed read finds no profile for is known unnamed; a failed + // read leaves its name missing, which only keeps the local step off. + if (!roster && members.length) + await session.profiles + .ensure(members.slice(0, 1024), "background") + .catch(() => {}); + if (invite) { + const release = session.agentChoices.retain(); + try { + session.agentChoices.ensure(true); + await settled( + session.agentChoices.subscribe, + session.agentChoices.snapshot, + (agents) => !agents.pending || agents.status === "ready", + signal, + ); + } finally { + release(); + } + } + const kit = fullSession(session)?.channelKit; + if (kit && teamBlocksSpace(kit.snapshot(), name)) return null; + const local = new Set( + mentionCandidates( + session, + channelId, + inviteAgents, + roster, + undefined, + elsewhere, + ).map((c) => c.recipient.pubkey), + ); + const profiles = session.profiles.snapshot(); + const unnamed = session.profiles.unnamed?.(); + const missingNames = channel?.members?.some( + (key) => !profiles.has(key) && !unnamed?.has(key), + ); + const decide = ( + people: MentionDirectoryPage["people"], + complete: boolean, + query: string, + ): SpaceMentionResolution => { + if (kit && teamBlocksSpace(kit.snapshot(), query)) return null; + const choices = namedCandidates( + session, + channelId, + inviteAgents, + roster, + selected, + people, + elsewhere, + ); + const pubkey = spaceMention(choices, local, query, { + pending: false, + missingNames, + complete, + }); + const choice = choices.find((c) => c.recipient.pubkey === pubkey); + if (choice) { + // Nobody can be added to a DM, and the chooser row that says "Will not + // be notified" was never shown: a chip must not promise a notification. + if ( + channel?.channelType === "dm" && + !members.includes(choice.recipient.pubkey) + ) + return null; + return choice.recipient; + } + if (!complete) return null; + // Over the complete set a longer name is the first thing the rule checks, + // so these are what kept the name from selecting. Each resolves over the + // same set: every name that equals or continues it also continues `query`. + const longer = continuingNames(choices, query); + if (!longer.length) return null; + return { + continued: longer.map((name) => ({ + name, + resolve: async (signal: AbortSignal) => + signal.aborted ? null : decide(people, true, name), + })), + }; + }; + // Once every member is named, an exact local name selects without the + // directory, as it does in the chooser. + const direct = decide([], false, name); + if (direct) return direct; + const outside = + !roster && + !invite && + allowsOutsideMentions(channel) && + !channel?.archived && + !channel?.readOnly; + if (!outside) return decide([], true, name); + const page = await fetchMentionDirectoryPage(session, name, signal); + // A truncated page may hide a namesake, as it does for Space in the chooser. + if (signal.aborted || page.more) return null; + return decide(page.people, true, name); +} + +/** Resolves once `done` holds for the store's snapshot; rejects on abort. */ +function settled( + subscribe: (listener: () => void) => () => void, + snapshot: () => T, + done: (value: T) => boolean, + signal: AbortSignal, +) { + return new Promise((resolve, reject) => { + let stop = () => {}; + const check = () => { + if (!done(snapshot())) return; + stop(); + resolve(); + }; + const abort = () => { + stop(); + reject(signal.reason ?? new DOMException("Aborted", "AbortError")); + }; + const unsubscribe = subscribe(check); + signal.addEventListener("abort", abort, { once: true }); + stop = () => { + unsubscribe(); + signal.removeEventListener("abort", abort); + }; + if (signal.aborted) abort(); + else check(); + }); +} diff --git a/src/bundled/mentions/solo-agents.test.tsx b/src/bundled/mentions/solo-agents.test.tsx index e7c201b341..3d7191d0a7 100644 --- a/src/bundled/mentions/solo-agents.test.tsx +++ b/src/bundled/mentions/solo-agents.test.tsx @@ -39,7 +39,18 @@ 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"; -import { MENTION_DIRECTORY_DELAY_MS } from "./useMentionDirectory"; +import { + fetchMentionDirectoryPage, + MENTION_DIRECTORY_DELAY_MS, +} from "./useMentionDirectory"; +import { + resolveSpaceMention, + type SpaceMentionResolution, +} from "./resolve-space-mention"; +import { MENTION_RESOLVE_TIMEOUT_MS } from "../../features/conversation/completion"; +import { createProfileDirectory } from "../../features/relay/profile-directory"; +import type { RelayEvent } from "../../features/relay/events"; +import { keypair, profile } from "../../features/relay/testing"; // jsdom lacks scrollIntoView; the search highlight keeps its row in view. beforeEach(() => { Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { @@ -1464,11 +1475,12 @@ it("shows local rows before the directory, appends outside rows, and reuses sett expect(last()?.status).toBe("Searching community…"); await act(() => vi.advanceTimersByTimeAsync(200)); expect(people).toHaveBeenCalledTimes(1); + // A truncated page: a longer query cannot be read out of it, so it searches. await act(async () => - pages.get("La")?.({ people: [lara, larry], hasMore: false }), + pages.get("La")?.({ people: [lara, larry], hasMore: true }), ); expect(labels()).toEqual(["Larkin", "Lara", "Larry Outside"]); - expect(last()?.status).toBeUndefined(); + expect(last()?.status).toBe("Narrow your search to see more members."); // A new query keeps still-matching people from the last page while it loads. view.rerender( , ); expect(labels()).toEqual(["Larkin", "Lara", "Larry Outside"]); - expect(last()?.status).toBeUndefined(); + expect(last()?.status).toBe("Narrow your search to see more members."); await act(() => vi.advanceTimersByTimeAsync(200)); expect(people).toHaveBeenCalledTimes(2); }); @@ -1882,8 +1894,11 @@ it("closes prose after an unknown name without status or recovery, and skips sea await waitFor(() => expect(last()?.items.map((item) => item.label)).toEqual(["Outside"]), ); + // The complete page for `Out` already answers every query extending it: + // `Outside ` is its one name plus a trailing space, prose, with no read. view.rerender(complete("Outside ")); - await waitFor(() => expect(people).toHaveBeenCalledTimes(3)); + await waitFor(() => expect(last()).toEqual({ items: [] })); + expect(people).toHaveBeenCalledTimes(2); }); function directoryCompletion(people: RelaySessionPeople) { @@ -1909,12 +1924,184 @@ function directoryCompletion(people: RelaySessionPeople) { publish={publish} /> ); - return { last, complete }; + return { last, complete, session }; } type RelaySessionPeople = ( query: string, ) => Promise<{ people: { pubkey: string; name: string }[]; hasMore: boolean }>; +it("answers a longer query from the complete page already read for its prefix, with no read and no typing pause", async () => { + vi.useFakeTimers(); + const people = vi.fn(async () => ({ + people: [ + { pubkey: "e".repeat(64), name: "Mary Jane" }, + { pubkey: "f".repeat(64), name: "Mark" }, + ], + hasMore: false, + })); + const { last, complete } = directoryCompletion(people); + const view = render(complete("Mar")); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(1); + expect(last()?.items.map((item) => item.label)).toEqual([ + "Mark", + "Mary Jane", + ]); + // Every match for `Mary J` is on the complete page for `Mar`: the page is + // settled on the keystroke, so the multi-word query is admitted at once. + view.rerender(complete("Mary J")); + expect(last()?.items.map((item) => item.label)).toEqual(["Mary Jane"]); + expect(last()?.status).toBeUndefined(); + expect(last()?.spacePending).toBeUndefined(); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(1); + // Space decides over the derived page, as over the page itself. + view.rerender(complete("Mary Jane")); + expect(last()?.spaceId).toBe("e".repeat(64)); + expect(last()?.spacePending).toBeUndefined(); + expect(people).toHaveBeenCalledTimes(1); +}); + +it("still searches a longer query when the page read for its prefix was truncated", async () => { + vi.useFakeTimers(); + const people = vi.fn(async (query) => ({ + people: [{ pubkey: "e".repeat(64), name: "Mary Jane" }], + hasMore: query === "Mar", + })); + const { last, complete } = directoryCompletion(people); + const view = render(complete("Mar")); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(last()?.status).toBe("Narrow your search to see more members."); + // A truncated page may have left a match out: it answers only its own query. + view.rerender(complete("Mary J")); + expect(last()?.status).toBe("Searching community…"); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(2); + expect(people).toHaveBeenLastCalledWith("Mary J", 1, expect.any(AbortSignal)); + expect(last()?.status).toBeUndefined(); + expect(last()?.items.map((item) => item.label)).toEqual(["Mary Jane"]); +}); + +it("does not answer a name search from the listing read for an empty query", async () => { + vi.useFakeTimers(); + const people = vi.fn(async () => ({ + people: [{ pubkey: "e".repeat(64), name: "Mira Everly" }], + hasMore: false, + })); + const { last, complete } = directoryCompletion(people); + // A pause on `@` lists the community; fewer than 15 profiles completes it. + const view = render(complete("")); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledExactlyOnceWith( + "", + 1, + expect.any(AbortSignal), + ); + // The listing is a prefix of every query but is not a name search: a + // member who joins or renames after it must still be found by name. + view.rerender(complete("mi")); + expect(last()?.status).toBe("Searching community…"); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(2); + expect(people).toHaveBeenLastCalledWith("mi", 1, expect.any(AbortSignal)); + expect(last()?.items.map((item) => item.label)).toEqual(["Mira Everly"]); + // The finished name search still answers the queries extending it. + view.rerender(complete("mir")); + expect(last()?.status).toBeUndefined(); + expect(last()?.items.map((item) => item.label)).toEqual(["Mira Everly"]); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(2); +}); + +it("a retry searches afresh when a complete page for a prefix of the query was cached meanwhile", async () => { + const people = vi + .fn() + .mockRejectedValueOnce(new Error("offline")) + .mockResolvedValue({ + people: [{ pubkey: "e".repeat(64), name: "Mira Everly" }], + hasMore: false, + }); + const { last, complete, session } = directoryCompletion(people); + render(complete("mira e")); + await waitFor(() => + expect(last()?.status).toBe( + "Could not search community people. Retry to refresh.", + ), + ); + // Another consumer of the session (a thread composer, or the pending- + // mention resolver) reads the complete page for `mira` meanwhile. + await fetchMentionDirectoryPage(session, "mira", AbortSignal.timeout(1000)); + expect(people).toHaveBeenCalledTimes(2); + // Retry asked for a read: it must not derive from that page, which the + // chooser could not show, but read `mira e` again and render its page. + act(() => last()?.retry?.()); + await waitFor(() => expect(people).toHaveBeenCalledTimes(3)); + expect(people).toHaveBeenLastCalledWith("mira e", 1, expect.any(AbortSignal)); + await waitFor(() => + expect(last()?.items.map((item) => item.label)).toEqual(["Mira Everly"]), + ); + expect(last()?.status).toBeUndefined(); + expect(last()?.retry).toBeUndefined(); +}); + +it("scopes a retry to its query: later queries derive from a cached prefix page and a refuted prefix again, while that query still reads afresh", async () => { + vi.useFakeTimers(); + const offline = new Error("offline"); + const people = vi + .fn() + .mockRejectedValueOnce(offline) + .mockRejectedValueOnce(offline) + .mockImplementation(async (query) => ({ + people: query.startsWith("mira") + ? [{ pubkey: "e".repeat(64), name: "Mira Everly" }] + : query.startsWith("lu") + ? [{ pubkey: "f".repeat(64), name: "Lucy Lu" }] + : [], + hasMore: false, + })); + const { last, complete, session } = directoryCompletion(people); + const failed = "Could not search community people. Retry to refresh."; + // The picker keeps one hook mounted across openings: one instance sees + // every query of the session. + const view = render(complete("mira e")); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(last()?.status).toBe(failed); + // Still offline: the retry fails too and Retry stays offered. + act(() => last()?.retry?.()); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(2); + expect(last()?.status).toBe(failed); + // Other consumers of the session settle complete `lu` and `mira` pages and + // then an empty `zed` search meanwhile (a read clears the refuted prefix, + // so the empty search comes last). + const signal = new AbortController().signal; + await fetchMentionDirectoryPage(session, "lu", signal); + await fetchMentionDirectoryPage(session, "mira", signal); + await fetchMentionDirectoryPage(session, "zed", signal); + expect(people).toHaveBeenCalledTimes(5); + // The retry was `mira e`'s. `lucy` is answered from the `lu` page on the + // keystroke, and `zedd` is refuted by the empty `zed` search; neither reads. + view.rerender(complete("lucy")); + expect(last()?.status).toBeUndefined(); + expect(last()?.items.map((item) => item.label)).toEqual(["Lucy Lu"]); + view.rerender(complete("zedd")); + expect(last()?.status).not.toBe("Searching community…"); + expect(last()?.items).toEqual([]); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(5); + // Back on `mira e`, the asked-for read is still owed: its failure and + // Retry stand, and a second Retry reads `mira e` afresh rather than + // deriving it from the complete `mira` page. + view.rerender(complete("mira e")); + expect(last()?.status).toBe(failed); + act(() => last()?.retry?.()); + await act(() => vi.advanceTimersByTimeAsync(MENTION_DIRECTORY_DELAY_MS)); + expect(people).toHaveBeenCalledTimes(6); + expect(people).toHaveBeenLastCalledWith("mira e", 1, expect.any(AbortSignal)); + expect(last()?.status).toBeUndefined(); + expect(last()?.items.map((item) => item.label)).toEqual(["Mira Everly"]); +}); + it("an empty partial-key search does not refute an exact-key lookup, and keys still match no choice", async () => { const key = "9".repeat(64); const people = vi.fn(async (query: string) => ({ @@ -2201,8 +2388,9 @@ it("keeps still-matching directory people across the inline host's per-keystroke await waitFor(() => expect(people).toHaveBeenCalledWith("La", 1, expect.anything()), ); + // A truncated page, so the longer query below searches rather than reads it. await act(async () => - resolvers.get("La")?.({ people: [lara, larry], hasMore: false }), + resolvers.get("La")?.({ people: [lara, larry], hasMore: true }), ); await waitFor(() => expect(options()).toEqual([ @@ -2672,6 +2860,7 @@ type DirectoryPage = { function spaceFixture( memberName: string, others: Record = {}, + options: { query?: string; dm?: boolean } = {}, ) { const h = setup(); h.profiles.set(h.member, { name: memberName }); @@ -2682,11 +2871,12 @@ function spaceFixture( channels: [ { id: "parent", - channelType: "stream" as const, + channelType: options.dm ? ("dm" as const) : ("stream" as const), members: [h.member, ...Object.keys(others)], }, ], }; + const query = options.query ?? "jose"; let finish: (value: DirectoryPage) => void = () => {}; let fail: (error: Error) => void = () => {}; const people = vi.fn( @@ -2704,25 +2894,26 @@ function spaceFixture( const publish = vi.fn(); const result = () => publish.mock.lastCall?.[0] as CompletionResult | undefined; + const session = { + ...h.session, + names, + channels: { ...h.session.channels, list: () => list }, + directMessages: { ...h.session.directMessages, people }, + } as unknown as RelaySession; + const end = query.length + 1; const view = render( list }, - directMessages: { ...h.session.directMessages, people }, - } as unknown as RelaySession - } + session={session} scope="space-test" channelId="parent" - observation={{ revision: 1, text: "@jose", start: 5, end: 5 }} - query={{ start: 0, end: 5, query: "jose" }} + observation={{ revision: 1, text: `@${query}`, start: end, end }} + query={{ start: 0, end, query }} publish={publish} />, ); return { ...h, + session, people, result, finish: (page: DirectoryPage) => act(async () => finish(page)), @@ -2917,3 +3108,540 @@ it.each([ } }, ); +const outsideNamesake = { pubkey: "e".repeat(64), name: "José" }; +it.each([ + { case: "no match", page: { people: [], hasMore: false } }, + { + case: "a namesake", + page: { + people: [{ pubkey: outsideJose, name: "Jose" }, outsideNamesake], + hasMore: false, + }, + }, + { + case: "a longer name", + page: { + people: [{ pubkey: outsideJose, name: "Jose Luis" }], + hasMore: false, + }, + }, + { + case: "more pages", + page: { people: [{ pubkey: outsideJose, name: "Jose" }], hasMore: true }, + }, + { case: "a failed search", page: "error" as const }, +])( + "Space may hold an outside name as pending only until the directory settles with $case", + async ({ page }) => { + const h = spaceFixture("Member"); + try { + await waitFor(() => + expect(h.people).toHaveBeenCalledWith("jose", 1, expect.anything()), + ); + // Undecided, not declined: the search is still running. + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.result()?.spacePending?.name).toBe("jose"); + await (page === "error" ? h.fail() : h.finish(page)); + await waitFor(() => expect(h.result()?.spacePending).toBeUndefined()); + expect(h.result()?.spaceId).toBeUndefined(); + } finally { + h.dispose(); + } + }, +); +it("Space holds nothing once it can decide", async () => { + const h = spaceFixture("José"); + try { + await waitFor(() => expect(h.result()?.spaceId).toBe(h.member)); + expect(h.result()?.spacePending).toBeUndefined(); + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose" }], + hasMore: false, + }); + await waitFor(() => expect(h.result()?.spaceId).toBe(h.member)); + expect(h.result()?.spacePending).toBeUndefined(); + } finally { + h.dispose(); + } +}); +it("Space is declined, not pending, beside a loaded name that continues the query", async () => { + const h = spaceFixture("Mira Example", {}, { query: "mira" }); + try { + await waitFor(() => + expect(h.people).toHaveBeenCalledWith("mira", 1, expect.anything()), + ); + await waitFor(() => + expect(h.result()?.items.some((item) => item.id === h.member)).toBe(true), + ); + // The read is in flight, but `Mira Example` continues `mira` whatever it + // finds: Space is prose now and `@mira Example ` can still select it. + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.result()?.spacePending).toBeUndefined(); + await h.finish({ people: [], hasMore: false }); + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.result()?.spacePending).toBeUndefined(); + } finally { + h.dispose(); + } +}); +it.each(["jose ", "jose,", "", "jose luis"])( + "Space never holds %j as a pending mention", + async (query) => { + const h = spaceFixture("Member", {}, { query }); + try { + await waitFor(() => + expect(h.people).toHaveBeenCalledWith(query, 1, expect.anything()), + ); + expect(h.result()).toBeDefined(); + expect(h.result()?.spacePending).toBeUndefined(); + } finally { + h.dispose(); + } + }, +); +it("a saved team with the name never leaves Space pending", async () => { + // No roster: nothing local decides, and the directory search is running. + const h = teamFixture(undefined, { missingRoster: true }); + try { + await waitFor(() => expect(h.result()).toBeDefined()); + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.result()?.spacePending).toBeUndefined(); + } finally { + h.view.unmount(); + h.library.dispose(); + } +}); +it("Space holds an agent's name while an invite scope's inventory loads, and the resolver selects it once loaded", async () => { + const test = setup(false); + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const read = vi.fn(async () => { + await gate; + return { + definitions: [], + identities: [{ pubkey: test.key, name: "Aaron" }], + }; + }); + const library = createAgentLibrary(read); + const session = { + ...test.session, + agentChoices: createAgentChoices({ + scope: "test", + library: library.queries, + signal: new AbortController().signal, + }), + }; + const publish = vi.fn(); + const result = () => publish.mock.lastCall?.[0] as CompletionResult; + const view = render( + , + ); + try { + await waitFor(() => expect(read).toHaveBeenCalledOnce()); + expect(result().spaceId).toBeUndefined(); + expect(result().spacePending?.name).toBe("Aaron"); + const pending = result().spacePending?.resolve( + new AbortController().signal, + ); + await act(async () => release()); + await expect(pending).resolves.toEqual({ pubkey: test.key, name: "Aaron" }); + // The resolver joined the read in flight rather than starting another. + expect(read).toHaveBeenCalledOnce(); + await waitFor(() => expect(result().spaceId).toBe(test.key)); + expect(result().spacePending).toBeUndefined(); + } finally { + await act(async () => release()); + view.unmount(); + library.dispose(); + test.library.dispose(); + } +}); +const resolver = ( + h: { session: RelaySession }, + signal = new AbortController().signal, +) => + resolveSpaceMention( + h.session, + { channelId: "parent", selected: [] }, + "jose", + signal, + ); +it.each([ + { + case: "a unique outside name", + page: { people: [{ pubkey: outsideJose, name: "Jose" }], hasMore: false }, + recipient: { pubkey: outsideJose, name: "Jose" }, + }, + { + case: "a namesake", + page: { + people: [{ pubkey: outsideJose, name: "Jose" }, outsideNamesake], + hasMore: false, + }, + recipient: null, + }, + { + // Only a longer name kept it from selecting: the host may extend the chip. + case: "a longer name", + page: { + people: [{ pubkey: outsideJose, name: "Jose Luis" }], + hasMore: false, + }, + recipient: { continued: ["Jose Luis"] }, + }, + { + case: "a longer name on a truncated page", + page: { + people: [{ pubkey: outsideJose, name: "Jose Luis" }], + hasMore: true, + }, + recipient: null, + }, + { + case: "more pages", + page: { people: [{ pubkey: outsideJose, name: "Jose" }], hasMore: true }, + recipient: null, + }, + { case: "a failed search", page: "error" as const, recipient: null }, +])( + "the resolver settles an outside name once the directory reports $case", + async ({ page, recipient }) => { + const h = spaceFixture("Member"); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + const pending = resolver(h); + // The chooser's read is in flight and uncached; the resolver reads at + // once, with no typing pause. + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(2)); + await (page === "error" ? h.fail() : h.finish(page)); + expect(outcomeOf(await pending)).toEqual(recipient); + } finally { + h.dispose(); + } + }, +); +/** A resolution for assertions: the recipient, null, or the continuing names. */ +const outcomeOf = (outcome: SpaceMentionResolution | undefined) => + outcome && "continued" in outcome + ? { continued: outcome.continued.map((c) => c.name) } + : outcome; +const continuedOf = (outcome: SpaceMentionResolution) => { + if (!outcome || !("continued" in outcome)) + throw new Error(`Not continued: ${JSON.stringify(outcome)}`); + return outcome.continued; +}; +it("the resolver's continuing names each resolve under the same rule over the page already read", async () => { + const h = spaceFixture("Member"); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + const pending = resolver(h); + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(2)); + const garcia = { pubkey: "e".repeat(64), name: "Jose Luis Garcia" }; + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose Luis" }, garcia], + hasMore: false, + }); + const continued = continuedOf(await pending); + expect(continued.map((c) => c.name)).toEqual([ + "Jose Luis", + "Jose Luis Garcia", + ]); + const signal = new AbortController().signal; + // `Jose Luis` is itself continued; the longest name selects. + expect(outcomeOf(await continued[0]?.resolve(signal))).toEqual({ + continued: ["Jose Luis Garcia"], + }); + await expect(continued[1]?.resolve(signal)).resolves.toEqual(garcia); + await expect( + continued[1]?.resolve(AbortSignal.abort()), + ).resolves.toBeNull(); + // Every name that equals or continues a longer name is on the page for + // its prefix, so no further read is needed. + expect(h.people).toHaveBeenCalledTimes(2); + } finally { + h.dispose(); + } +}); +it("the resolver's continuing name in a DM resolves to prose when it is an outsider", async () => { + const h = spaceFixture("Member", {}, { dm: true }); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + const pending = resolver(h); + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(2)); + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose Luis" }], + hasMore: false, + }); + const continued = continuedOf(await pending); + expect(continued.map((c) => c.name)).toEqual(["Jose Luis"]); + await expect( + continued[0]?.resolve(new AbortController().signal), + ).resolves.toBeNull(); + } finally { + h.dispose(); + } +}); +it("the resolver continues a roster name into the roster's longer name without the directory", async () => { + const h = spaceFixture("Jose"); + try { + const luis = { pubkey: outsideJose, name: "Jose Luis" }; + const scope = { + channelId: "parent", + roster: { recipients: [{ pubkey: h.member, name: "Josefa" }, luis] }, + selected: [], + }; + const signal = new AbortController().signal; + const continued = continuedOf( + await resolveSpaceMention(h.session, scope, "jose", signal), + ); + expect(continued.map((c) => c.name)).toEqual(["Jose Luis"]); + await expect(continued[0]?.resolve(signal)).resolves.toEqual(luis); + expect(h.people).not.toHaveBeenCalled(); + } finally { + h.dispose(); + } +}); +it("the resolver reads a page the chooser already settled from the shared cache", async () => { + const h = spaceFixture("Member"); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose" }], + hasMore: false, + }); + await expect(resolver(h)).resolves.toEqual({ + pubkey: outsideJose, + name: "Jose", + }); + expect(h.people).toHaveBeenCalledTimes(1); + } finally { + h.dispose(); + } +}); +it("the resolver reads a longer name's page out of the complete page for its prefix without another read", async () => { + const h = spaceFixture("Member"); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose Luis" }], + hasMore: false, + }); + // The chooser's complete page for `jose` holds every match for `jose luis`. + await expect( + resolveSpaceMention( + h.session, + { channelId: "parent", selected: [] }, + "jose luis", + new AbortController().signal, + ), + ).resolves.toEqual({ pubkey: outsideJose, name: "Jose Luis" }); + expect(h.people).toHaveBeenCalledTimes(1); + } finally { + h.dispose(); + } +}); +it("the resolver returns a DM outsider to prose rather than promise a notification", async () => { + const h = spaceFixture("Member", {}, { dm: true }); + try { + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + const pending = resolver(h); + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(2)); + await h.finish({ + people: [{ pubkey: outsideJose, name: "Jose" }], + hasMore: false, + }); + await expect(pending).resolves.toBeNull(); + } finally { + h.dispose(); + } +}); +it("the resolver settles to prose when its chip is gone before the directory answers", async () => { + const h = spaceFixture("Member"); + try { + const controller = new AbortController(); + const pending = resolver(h, controller.signal); + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(2)); + controller.abort(); + await expect(pending).resolves.toBeNull(); + } finally { + h.dispose(); + } +}); +it("the resolver settles to prose for a chip already gone, even where Space could decide at once", async () => { + const h = spaceFixture("Jose"); + try { + // A roster scope with an exact local match awaits nothing before it picks. + const scope = { + channelId: "parent", + roster: { recipients: [{ pubkey: h.member, name: "Jose" }] }, + selected: [], + }; + await expect( + resolveSpaceMention(h.session, scope, "jose", AbortSignal.abort()), + ).resolves.toBeNull(); + await expect( + resolveSpaceMention( + h.session, + scope, + "jose", + new AbortController().signal, + ), + ).resolves.toEqual({ pubkey: h.member, name: "Jose" }); + } finally { + h.dispose(); + } +}); +it("the resolver gives up at the wait bound", async () => { + vi.useFakeTimers(); + const h = spaceFixture("Member"); + try { + const pending = resolver(h); + await vi.advanceTimersByTimeAsync(MENTION_RESOLVE_TIMEOUT_MS); + await expect(pending).resolves.toBeNull(); + } finally { + h.dispose(); + } +}); +/** + * A real profile directory, so a member read that settles or fails is evidence + * the chooser sees. The directory search never settles: only step 1 of the + * Space rule can select here. + */ +function unnamedMemberFixture() { + const h = setup(); + const named = keypair(); + const reads: { + authors: readonly string[]; + resolve: (events: RelayEvent[]) => void; + reject: (error: Error) => void; + }[] = []; + const directory = createProfileDirectory({ + read: (filters) => + new Promise((resolve, reject) => { + reads.push({ authors: filters[0]?.authors ?? [], resolve, reject }); + }), + }); + directory.accept([profile(named, { name: "José" })]); + const list = { + status: "ready" as const, + channels: [ + { + id: "parent", + channelType: "stream" as const, + members: [named.pubkey, otherMember], + }, + ], + }; + const names = bindNames( + { profiles: directory.queries, agentLibrary: h.library.queries }, + { snapshot: () => [createAgentDirectory()], subscribe: () => () => {} }, + ); + const publish = vi.fn(); + const result = () => + publish.mock.lastCall?.[0] as CompletionResult | undefined; + const view = render( + list }, + directMessages: { + ...h.session.directMessages, + people: () => new Promise(() => {}), + }, + } as unknown as RelaySession + } + scope="space-test" + channelId="parent" + observation={{ revision: 1, text: "@jose", start: 5, end: 5 }} + query={{ start: 0, end: 5, query: "jose" }} + publish={publish} + />, + ); + return { + member: named.pubkey, + reads, + directory, + result, + canSpace: (id: string) => + result() + ?.items.find((item) => item.id === id) + ?.canSelect?.(" "), + dispose() { + view.unmount(); + names.dispose(); + directory.dispose(); + h.library.dispose(); + }, + }; +} +it("Space selects a member's name once a read finds no profile for another member", async () => { + const h = unnamedMemberFixture(); + try { + await waitFor(() => + expect(h.reads.map((read) => read.authors)).toEqual([[otherMember]]), + ); + await waitFor(() => + expect(h.result()?.items.some((item) => item.id === h.member)).toBe(true), + ); + // While its read is in flight the other member may still be a namesake. + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.canSpace(h.member)).toBe(false); + await act(async () => h.reads[0]?.resolve([])); + // Known unnamed: no name to be a namesake, and no directory wait. + await waitFor(() => expect(h.result()?.spaceId).toBe(h.member)); + expect(h.canSpace(h.member)).toBe(true); + } finally { + h.dispose(); + } +}); +it("Space holds a member's name while another member's read is in flight, and the resolver selects it once the read settles", async () => { + const h = unnamedMemberFixture(); + try { + await waitFor(() => + expect(h.reads.map((read) => read.authors)).toEqual([[otherMember]]), + ); + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.result()?.spacePending?.name).toBe("jose"); + const pending = h + .result() + ?.spacePending?.resolve(new AbortController().signal); + await act(async () => h.reads[0]?.resolve([])); + // Known unnamed: the member's exact name selects without the directory. + await expect(pending).resolves.toEqual({ pubkey: h.member, name: "José" }); + // The resolver joined the read in flight rather than issuing another. + expect(h.reads).toHaveLength(1); + } finally { + h.dispose(); + } +}); +it("Space keeps waiting after a member's profile read fails until a retry settles it", async () => { + const h = unnamedMemberFixture(); + try { + await waitFor(() => expect(h.reads).toHaveLength(1)); + await act(async () => h.reads[0]?.reject(new Error("offline"))); + // A failed read is not evidence that the member has no name. + expect(h.directory.queries.unnamed?.()).toEqual(new Set()); + expect(h.result()?.spaceId).toBeUndefined(); + expect(h.canSpace(h.member)).toBe(false); + act(() => h.result()?.retry?.()); + await waitFor(() => expect(h.reads).toHaveLength(2)); + expect(h.reads[1]?.authors).toEqual([otherMember]); + await act(async () => h.reads[1]?.resolve([])); + await waitFor(() => expect(h.result()?.spaceId).toBe(h.member)); + expect(h.canSpace(h.member)).toBe(true); + } finally { + h.dispose(); + } +}); diff --git a/src/bundled/mentions/team-mentions.test.tsx b/src/bundled/mentions/team-mentions.test.tsx index 1e6ccb32ce..e8b2b46595 100644 --- a/src/bundled/mentions/team-mentions.test.tsx +++ b/src/bundled/mentions/team-mentions.test.tsx @@ -594,10 +594,11 @@ it("directory pages rebuild the name index only for people they add", async () = 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. + // Every settled search returns a new array. Pages are truncated so each + // keystroke reads rather than deriving from the complete `p` page. vi.mocked(h.session.directMessages.people).mockImplementation(async () => ({ people: [...page], - hasMore: false, + hasMore: true, })); const starts = vi.spyOn(personMatch, "nameStarts"); function Chooser({ query }: { query: string }) { diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts index e12392ae9b..4dfd924def 100644 --- a/src/bundled/mentions/use-mention-choices.ts +++ b/src/bundled/mentions/use-mention-choices.ts @@ -8,6 +8,8 @@ import { useSyncExternalStore, } from "react"; import type { ComposerSession } from "../../features/messages/composer-session"; +import type { MentionRecipient } from "../../features/messages/mention-draft"; +import type { Agent } from "../../features/agents/service"; import { useAgentChoices } from "../../features/agents/use-choices"; import { AgentsContext, useAgentsSnapshot } from "../../features/agents/react"; import { useIdentityNames } from "../../features/identity-names/react"; @@ -45,6 +47,10 @@ const byKey = ( // 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. +// 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( @@ -60,6 +66,107 @@ function page(session: ComposerSession, people: readonly Person[]) { if (seen.size > 8) seen.delete(seen.keys().next().value ?? ""); return kept; } +/** The choice set under the labels users see: one naming scope per set, so + * namesakes are qualified. Directory people name only keys no other source knows. */ +export function namedCandidates( + session: ComposerSession, + channelId: string, + invite: boolean | undefined, + roster: readonly MentionRecipient[] | undefined, + selected: readonly MentionRecipient[], + people: readonly Person[], + elsewhere?: readonly Pick[], +): readonly MentionChoice[] { + 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, + 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), + ...selected.map((p) => p.pubkey), + ]), + ]; + // 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 facts = [ + ...outside + .filter((person) => !known?.(person.pubkey)) + .map(({ pubkey, name, isAgent }) => ({ + pubkey, + name, + ...(isAgent ? { isAgent } : {}), + })), + ...selected, + ]; + // One naming scope per choice set, not one normalization per row. + const name = session.names?.scope(keys, facts); + const choices: readonly MentionChoice[] = candidates.map((choice) => { + const named = name?.(choice.recipient.pubkey); + return { + ...choice, + label: named?.name ?? choice.recipient.name, + qualifier: named?.qualifier, + }; + }); + labeled.set(candidates, { sources, choices }); + return choices; +} +/** What the chooser knows about the choice set's completeness. */ +export type SpaceEvidence = { + /** Members or offered agents are still loading. */ + pending: boolean; + /** A member's name is neither known nor known to be absent. */ + missingNames: boolean | undefined; + /** The directory search for this query settled with no more pages. */ + complete: boolean; +}; +/** + * Space (README section 5): once every member is named, an exact local name + * selects at once. Any other exact name needs the full choice set: while the + * directory search runs, failed, or has more pages, a namesake may be missing. + */ +export function spaceMention( + all: readonly MentionChoice[], + local: ReadonlySet, + query: string, + { pending, missingNames, complete }: SpaceEvidence, +) { + if (pending) return undefined; + const choices = matchingMentions(all, query); + return ( + (missingNames + ? undefined + : exactMention(choices, query, (c) => local.has(c.recipient.pubkey))) ?? + (complete ? exactMention(choices, query) : undefined) + ); +} /** * One installed key list per opening/query. Evidence stays live, order does not. * Local rows install first; directory rows only append after them. @@ -73,7 +180,8 @@ export function useMentionChoices( lifetime: string, open = true, ) { - const roster = useContext(DraftMentionRoster); + const draft = useContext(DraftMentionRoster); + const roster = draft?.recipients; const selected = useContext(SelectedMentionContext); const agents = useAgentChoices(session, !!invite && open); // The owner's agents in their other communities; the snapshot is the revision. @@ -84,6 +192,11 @@ export function useMentionChoices( session.profiles.snapshot, session.profiles.snapshot, ); + const unnamed = useSyncExternalStore( + session.profiles.subscribe, + () => session.profiles.unnamed?.(), + () => session.profiles.unnamed?.(), + ); const list = useSyncExternalStore( session.channels.subscribeList, session.channels.list, @@ -117,76 +230,19 @@ export function useMentionChoices( ...carried.filter((person) => !known.has(person.pubkey)), ]; }, [directory.people, carried]); - const current = useCallback(() => { - 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, - 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), - ...selected.map((p) => p.pubkey), - ]), - ]; - // 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 facts = [ - ...outside - .filter((person) => !known?.(person.pubkey)) - .map(({ pubkey, name, isAgent }) => ({ - pubkey, - name, - ...(isAgent ? { isAgent } : {}), - })), - ...selected, - ]; - // One naming scope per choice set, not one normalization per row. - const name = session.names?.scope(keys, facts); - const choices: readonly MentionChoice[] = candidates.map((choice) => { - const named = name?.(choice.recipient.pubkey); - return { - ...choice, - label: named?.name ?? choice.recipient.name, - qualifier: named?.qualifier, - }; - }); - labeled.set(candidates, { sources, choices }); - return choices; - }, [session, channelId, invite, roster, selected, people, community]); + const current = useCallback( + () => + namedCandidates( + session, + channelId, + invite, + roster, + selected, + people, + community?.joinable(), + ), + [session, channelId, invite, roster, selected, people, community], + ); // Keys known without the directory search: members and agent choices. const localKeys = useCallback(() => { const local = mentionCandidates( @@ -236,6 +292,7 @@ export function useMentionChoices( !channel?.members; const pending = membersPending || + !!draft?.pending || (!roster && !!invite && agents.pending && agents.status !== "ready"); let rows = same ? installed.rows : undefined; if (!same && installed) install(undefined); @@ -251,8 +308,10 @@ export function useMentionChoices( const outside = open ? rank(matches.filter((c) => !local.has(c.recipient.pubkey))) : []; + // A member a completed read found no profile for has no name to be a namesake. const missingNames = - open && channel?.members?.some((key) => !profiles.has(key)); + open && + channel?.members?.some((key) => !profiles.has(key) && !unnamed?.has(key)); if (open && !rows && !membersPending) { const ranked = rank( matches.filter((c) => local.has(c.recipient.pubkey)), @@ -296,25 +355,9 @@ export function useMentionChoices( }); } } - // Space (README section 5): once every member is named, an exact local name - // selects at once. Any other exact name needs the full choice set: while the - // 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 = ( - 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 evidence = { pending, missingNames, complete }; const live = byKey(candidates); const choices = (rows ?? []).map((row) => { const now = live.get(row.recipient.pubkey); @@ -342,7 +385,7 @@ export function useMentionChoices( candidates, pending, archives, - spaceId: spaceMention(candidates, local), + spaceId: spaceMention(candidates, local, query, evidence), canSelect: (pubkey: string, space = false) => { const fresh = current(); return ( @@ -352,7 +395,8 @@ export function useMentionChoices( (!rows?.find((row) => row.recipient.pubkey === pubkey)?.member || c.member), ) && - (!space || spaceMention(fresh, localKeys()) === pubkey) + (!space || + spaceMention(fresh, localKeys(), query, evidence) === pubkey) ); }, truncated: diff --git a/src/bundled/mentions/use-team-mentions.ts b/src/bundled/mentions/use-team-mentions.ts index 288e19b13a..1b31596929 100644 --- a/src/bundled/mentions/use-team-mentions.ts +++ b/src/bundled/mentions/use-team-mentions.ts @@ -6,9 +6,25 @@ import { import type { useMentionChoices } from "./use-mention-choices"; import { mentionCandidates } from "./mention-candidates"; import { extendsName, normalizeName } from "../../features/search/person-match"; +import type { RelaySession } from "../../features/relay/session"; import { mentionMatch } from "./mention-ranking"; const empty = { status: "unavailable", entries: [] } as const; +type Catalog = Pick< + ReturnType["snapshot"]>, + "entries" +>; +/** A saved team with the same or a longer name must not auto-select a person on Space. */ +export function teamBlocksSpace(catalog: Catalog, query: string) { + const needle = normalizeName(query); + return catalog.entries.some( + (entry) => + !entry.record.deleted && + entry.record.value.type === "team" && + (normalizeName(entry.record.value.name) === needle || + extendsName(entry.record.value.name, needle)), + ); +} const subscribeNone = () => () => {}; const snapshotNone = () => empty; @@ -176,15 +192,11 @@ export function useTeamMentions( }, ); const names = teams.map(({ value }) => value.name); - const needle = normalizeName(query); return { choices, includeLegacy: hasTeams, names, - // A saved team with the same/longer name must not auto-select a person on Space. - blocksSpace: names.some( - (name) => normalizeName(name) === needle || extendsName(name, needle), - ), + blocksSpace: teamBlocksSpace(catalog, query), status: open && (catalog.status === "idle" || catalog.status === "loading") ? "Loading saved teams…" diff --git a/src/bundled/mentions/useMentionDirectory.ts b/src/bundled/mentions/useMentionDirectory.ts index 76d24db243..2e378d3e53 100644 --- a/src/bundled/mentions/useMentionDirectory.ts +++ b/src/bundled/mentions/useMentionDirectory.ts @@ -2,15 +2,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import type { ComposerSession } from "../../features/messages/composer-session"; import type { ChannelSummary } from "../../features/relay/contracts"; import { allowsOutsideMentions } from "../../features/messages/mention-admission"; +import { matchPerson } from "../../features/search/person-match"; type Person = Awaited< ReturnType >["people"][number]; -type Page = { people: readonly Person[]; more: boolean }; +export type MentionDirectoryPage = { people: readonly Person[]; more: boolean }; +type Page = MentionDirectoryPage; const empty: readonly Person[] = []; /** Typing pause before an uncached query reaches the network. */ export const MENTION_DIRECTORY_DELAY_MS = 200; const CACHE_LIMIT = 100; +// An exact key is an author lookup, which name-prefix evidence cannot refute. +const exactKey = (query: string) => /^[0-9a-f]{64}$/.test(query.trim()); // Successful non-empty first pages, per session (community/viewer) and query. // Session lifetime bounds staleness; errors are not cached, so Retry reads @@ -31,6 +35,38 @@ function remember(session: ComposerSession, query: string, page: Page) { if (queries.size > CACHE_LIMIT) queries.delete(queries.keys().next().value ?? ""); } +/** + * The longest cached complete page for a proper prefix of `query`. The relay + * matches names by prefix, so a page for `mira` that reported no more pages + * already holds every match for `mira e`, `mira exam` and `mira example`; + * `derive` filters it to the longer query. A truncated page may have left a + * match out, and an exact key is an author lookup that no name prefix + * answers, so neither derives. Nor does the listing read for an empty query: + * it is a plain profile read, not a name search, and once complete (a + * community with fewer than 15 profiles) it is a prefix of every query, so it + * would answer each name search for the session and hide a member who joins + * or renames after it. + */ +function prefixPage(session: ComposerSession, query: string) { + const queries = pages.get(session); + if (!queries || exactKey(query)) return undefined; + let best: [string, Page] | undefined; + for (const [key, page] of queries) + if ( + key.trim().length > 0 && + !page.more && + key.length < query.length && + query.startsWith(key) && + (!best || key.length > best[0].length) + ) + best = [key, page]; + return best?.[1]; +} +/** The page for `query` read out of a complete page for its prefix. */ +const derive = (page: Page, query: string): Page => ({ + people: page.people.filter((person) => matchPerson(person.name, query)), + more: false, +}); // The last settled page per chooser lifetime. Inline completion remounts its // provider on every keystroke, so a component-local copy would be lost. const settled = new WeakMap>(); @@ -52,6 +88,38 @@ function settle(session: ComposerSession, lifetime: string, page: Page) { const exhausted = new WeakMap(); const refutedPage: Page = { people: empty, more: false }; +/** + * The first page for `query`: the shared cache, a complete cached page for a + * prefix of it, or one relay read that fills the cache. The chooser and the + * pending-mention resolver both read through here, so a page either has found + * is the other's cache hit. `fresh` skips the prefix page: a retry asked for + * a read, and a derived page is not cached, so the chooser could not show it. + */ +export async function fetchMentionDirectoryPage( + session: ComposerSession, + query: string, + signal: AbortSignal, + { fresh = false } = {}, +): Promise { + const hit = cached(session, query); + if (hit) return hit; + const source = fresh ? undefined : prefixPage(session, query); + if (source) return derive(source, query); + exhausted.delete(session); + const { people, hasMore } = await session.directMessages.people( + query, + 1, + signal, + ); + const page = { people, more: hasMore }; + if (signal.aborted) return page; + if (people.length) remember(session, query, page); + // The relay ignores non-word text, so it only refutes word prefixes. + else if (!hasMore && !exactKey(query) && /[\p{L}\p{N}]/u.test(query)) + exhausted.set(session, query); + return page; +} + /** * Directory pages belong to this menu and community, not the global profile * cache. While a new query waits or loads, the last settled page stays visible @@ -71,7 +139,12 @@ export function useMentionDirectory( allowsOutsideMentions(channel) && !channel?.archived && !channel?.readOnly; - const [attempt, setAttempt] = useState(0); + // A retry belongs to the query it was asked for. The picker keeps one hook + // mounted across openings, so a count that never reset would hold every + // later query of the session to a fresh read, off the prefix-page and + // refuted shortcuts, after one transient failure. + const [retried, setRetried] = useState<{ query: string; attempt: number }>(); + const attempt = retried?.query === query ? retried.attempt : 0; const [state, setState] = useState<{ session: ComposerSession; query: string; @@ -89,19 +162,25 @@ export function useMentionDirectory( state.lifetime === lifetime ? state : undefined; - // An exact key is an author lookup, which name-prefix evidence cannot refute. - const exactKey = /^[0-9a-f]{64}$/.test(query.trim()); const prefix = exhausted.get(session); const refuted = attempt === 0 && - !exactKey && + !exactKey(query) && prefix !== undefined && query.length > prefix.length && query.startsWith(prefix); + const exact = active ? cached(session, query) : undefined; + // Like the refuted shortcut, a prefix page serves only until a retry asks + // for a fresh read. Its identity keys the derived page, so the result is + // stable across renders until another read lands in the cache. + const source = + active && !exact && attempt === 0 ? prefixPage(session, query) : undefined; + const derived = useMemo( + () => (source ? derive(source, query) : undefined), + [source, query], + ); const hit = active - ? (cached(session, query) ?? - own?.page ?? - (refuted ? refutedPage : undefined)) + ? (exact ?? own?.page ?? derived ?? (refuted ? refutedPage : undefined)) : undefined; const searching = active && !hit; useEffect(() => { @@ -109,20 +188,13 @@ export function useMentionDirectory( const controller = new AbortController(); const current = { session, query, attempt, lifetime }; const timer = setTimeout(() => { - exhausted.delete(session); - void session.directMessages.people(query, 1, controller.signal).then( - ({ people, hasMore }) => { + void fetchMentionDirectoryPage(session, query, controller.signal, { + fresh: attempt > 0, + }).then( + (page) => { if (controller.signal.aborted) return; - const page = { people, more: hasMore }; - if (people.length) { - remember(session, query, page); - setState(current); - return; - } - // The relay ignores non-word text, so it only refutes word prefixes. - if (!hasMore && !exactKey && /[\p{L}\p{N}]/u.test(query)) - exhausted.set(session, query); - setState({ ...current, page }); + // A non-empty page is now cached; `own.page` keeps an empty one. + setState(page.people.length ? current : { ...current, page }); }, () => { if (!controller.signal.aborted) @@ -137,13 +209,16 @@ export function useMentionDirectory( clearTimeout(timer); controller.abort(); }; - }, [session, query, searching, exactKey, attempt, lifetime]); + }, [session, query, searching, attempt, lifetime]); if (hit) settle(session, lifetime, hit); const error = active && !hit ? own?.error : undefined; const shown = hit ?? settled.get(session)?.get(lifetime); const retry = useCallback(() => { pages.get(session)?.delete(query); - setAttempt((value) => value + 1); + setRetried((prior) => ({ + query, + attempt: (prior?.query === query ? prior.attempt : 0) + 1, + })); }, [session, query]); return useMemo( () => ({ diff --git a/src/features/conversation/ComposerCompletions.tsx b/src/features/conversation/ComposerCompletions.tsx index 5e69b253d9..ddfdd34be7 100644 --- a/src/features/conversation/ComposerCompletions.tsx +++ b/src/features/conversation/ComposerCompletions.tsx @@ -10,6 +10,7 @@ import { useCompletionPosition } from "./useCompletionPosition"; import { useId, useCallback, + useEffect, useLayoutEffect, useRef, useState, @@ -34,6 +35,37 @@ import styles from "./Completions.module.css"; const RETRY = Symbol("retry-completion"); +/** What the popup shows, owned by the host across the per-query remount of + * `OwnedCompletion`. A keystroke that continues an open query (same provider, + * same trigger) mounts a new owner; its first commit used to close the popup + * until the provider's mount effect published, a paint later. The new owner + * now shows the previous query's result until its own first publication + * replaces it in the same task: a store update renders synchronously, and an + * owner whose provider has not published by the end of its mount effects + * clears it. A query opened elsewhere, by another provider, or after the + * popup closed starts empty: `ComposerCompletions` evicts the store in its + * closed render, so a carried result means only a continuing open query. */ +function createDisplay() { + let shown: { key: string; result: CompletionResult | undefined } = { + key: "", + result: undefined, + }; + const listeners = new Set<() => void>(); + return { + read: (key: string) => (shown.key === key ? shown.result : undefined), + set(key: string, result: CompletionResult | undefined) { + shown = { key, result }; + for (const listener of listeners) listener(); + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + }; +} + // Channel links belong in prose, not inside existing links or code. // Run only after a channel trigger matches; ordinary typing does no extra parsing. function channelCompletionInProse( @@ -91,6 +123,8 @@ export function ComposerCompletions({ text: string; document?: ComposerSnapshot; recipients: readonly Readonly<{ start: number; end: number }>[]; + /** Mentions still resolving; their `@` is no more an open query. */ + pending?: readonly Readonly<{ start: number; end: number }>[] | undefined; }>; replace( edit: CompletionEdit, @@ -104,18 +138,33 @@ export function ComposerCompletions({ registry.snapshot, registry.snapshot, ); + const [display] = useState(createDisplay); const observation = editor.observation; // Stale chip ranges cannot classify this caret. const match = observation && resolved.text === observation.text && - matchCompletion(providers, observation, context, resolved.recipients); - if (!match || !observation) return null; - if ( - match.provider.pluginId === "buzz.channels" && - !channelCompletionInProse(observation.text, resolved.document, match.query) - ) - return null; + matchCompletion(providers, observation, context, [ + ...resolved.recipients, + ...(resolved.pending ?? []), + ]); + const open = + !!match && + !!observation && + (match.provider.pluginId !== "buzz.channels" || + channelCompletionInProse( + observation.text, + resolved.document, + match.query, + )); + // A closed popup carries nothing on. A query reopened at the same trigger, + // after Escape, Backspace through its `@`, a send or a pending-mention hold + // that returned to text, starts empty as a first query does; its provider's + // first publication fills it in the same task. + useEffect(() => { + if (!open) display.set("", undefined); + }, [open, display]); + if (!open || !match || !observation) return null; return ( ; registry: ContributionReader; + display: ReturnType; query: CompletionQuery; observation: ComposerObservation; editor: CompletionEditor; @@ -183,7 +235,13 @@ function OwnedCompletion({ : undefined, ); const list = useRef(null); - const [result, setResult] = useState(); + const trigger = `${contributionKey(provider)}:${query.start}`; + const result = useSyncExternalStore( + display.subscribe, + () => display.read(trigger), + () => display.read(trigger), + ); + // This lifetime's own publication; a carried display is never acted on. const latest = useRef(undefined); // Only user navigation pins an ID; automatic selection follows the first result. const [selected, setSelected] = useState(); @@ -209,7 +267,7 @@ function OwnedCompletion({ const next = completionResult(incoming); latest.current = next; revealSelection.current = true; - setResult(next); + display.set(trigger, next); setSelected((previous) => next.items.some((item) => item.id === previous) ? previous @@ -220,7 +278,7 @@ function OwnedCompletion({ return () => { if (latest.current !== next) return; latest.current = undefined; - if (live.current) setResult(undefined); + if (live.current) display.set(trigger, undefined); }; }); return () => { @@ -228,7 +286,14 @@ function OwnedCompletion({ live.current = false; latest.current = undefined; }; - }, [active]); + }, [active, display, trigger]); + // A carried result stands only until this query's provider has run its + // mount effects, which precede this one; a provider that has not published + // by then leaves the popup closed, as for a first query, so a stale display + // is never painted. + useEffect(() => { + if (publish && !latest.current) display.set(trigger, undefined); + }, [publish, display, trigger]); const items = result?.items ?? []; // Matching syntax is not evidence of pending work. Providers publish their // own status when needed; an empty/unpublished result must not flash a portal. @@ -250,8 +315,9 @@ function OwnedCompletion({ } // Publication is synchronous; React may still be displaying the previous // result. Keep the user's chosen ID, but use only its current evidence/edit. - const item = published.items.find((item) => item.id === items[index]?.id); - if (key === terminator && item?.id !== published.spaceId) return false; + // The terminator names its own item, which that display may not have yet. + const id = key === terminator ? published.spaceId : items[index]?.id; + const item = published.items.find((item) => item.id === id); if (!item || item.disabled || item.canSelect?.(key) === false) return false; const accepted = current.current.replace( item.edit, @@ -265,8 +331,9 @@ function OwnedCompletion({ useLayoutEffect(() => { const element = input.current; // Escape must still dismiss an unpublished provider without advertising a - // nonexistent listbox. A settled empty result leaves ordinary keys alone. - if (!element || (!visible && result)) return; + // nonexistent listbox. A settled empty result leaves ordinary keys alone; + // an undecided one still owns the terminator, with nothing to show yet. + if (!element || (!visible && result && !result.spacePending)) return; if (visible) { element.setAttribute("aria-autocomplete", "list"); element.setAttribute("aria-haspopup", "listbox"); @@ -300,15 +367,46 @@ function OwnedCompletion({ } return false; } - if (event.key === terminator && result?.spaceId) { - const exact = items.findIndex((item) => item.id === result.spaceId); - if (accept(exact, terminator)) { + // A result that has just decided the terminator may not have rendered + // yet, so both terminator branches read the publication. Space is not + // redirected away from an exact match the display showed: a publication + // that moves it to another id leaves Space literal. The displayed row + // is never accepted on its own; only the publication selects. + const published = latest.current; + if ( + event.key === terminator && + published?.spaceId && + (!result?.spaceId || result.spaceId === published.spaceId) + ) { + // Selected by ID, so no displayed row is needed. + if (accept(-1, terminator)) { event.preventDefault(); event.stopPropagation(); return true; } return false; } + // Undecided, not declined: the provider is still gathering evidence, so + // the host may hold the name as a pending mention and resolve it later. + // This includes a display whose exact match the publication withdrew + // as undecided: no other recipient overrules what the user saw, the + // decision is deferred to the resolver under the full rule. + if ( + event.key === terminator && + published?.spacePending && + !published.spaceId && + current.current.replace( + { pending: published.spacePending }, + query, + observation, + terminator, + ) + ) { + event.preventDefault(); + event.stopPropagation(); + current.current.editor.invalidate(); + return true; + } if ((event.key === "ArrowDown" || event.key === "ArrowUp") && count) { event.preventDefault(); event.stopPropagation(); diff --git a/src/features/conversation/completion.ts b/src/features/conversation/completion.ts index 9381a963fb..f80e768689 100644 --- a/src/features/conversation/completion.ts +++ b/src/features/conversation/completion.ts @@ -142,7 +142,28 @@ export function completionResult(result: CompletionResult): CompletionResult { ...(typeof result.spaceId === "string" && ids.has(result.spaceId) ? { spaceId: result.spaceId } : {}), + ...(result.spacePending && + typeof result.spacePending.name === "string" && + result.spacePending.name && + typeof result.spacePending.resolve === "function" + ? { + spacePending: Object.freeze({ + name: result.spacePending.name, + resolve: result.spacePending.resolve, + }), + } + : {}), ...(typeof result.status === "string" ? { status: result.status } : {}), ...(typeof result.retry === "function" ? { retry: result.retry } : {}), }); } +/** + * How long a pending mention waits for the evidence Space needed, counted + * once per name: the provider bounds its own `CompletionPending.resolve` by + * it (one membership read, one profile batch, one inventory read and one + * directory page, each a single relay round trip with no typing debounce), + * and the host holds each longer name a chip extends into to the same bound. + * Expiry returns the name to prose. Bounds both the chip and a send that + * waits on it. + */ +export const MENTION_RESOLVE_TIMEOUT_MS = 8000; diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index 176d265dd0..171cc10077 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -239,17 +239,47 @@ export type CompletionQuery = Readonly<{ end: number; query: string; }>; +/** A name the provider cannot decide on yet. The host may hold it as a chip + * with no recipient and ask `resolve` for the recipient the terminator would + * have selected once the provider's evidence is complete; null returns the + * name to prose. `resolve` is headless: the provider has unmounted by then. */ +export type CompletionPending = Readonly<{ + name: string; + resolve(signal: AbortSignal): Promise; +}>; +/** The recipient; null for prose; or, when only longer known names kept the + * name from selecting over the complete choice set, those names, each + * resolvable under the same rule. The host may extend the chip over the words + * typed after it when they complete one of them, and ask that one. */ +export type CompletionResolution = + | Readonly<{ pubkey: string; name: string }> + | Readonly<{ continued: readonly CompletionPending[] }> + | null; export type CompletionEdit = - | Readonly<{ text: string; mention?: never; mentions?: never }> + | Readonly<{ + text: string; + mention?: never; + mentions?: never; + pending?: never; + }> | Readonly<{ mention: Readonly<{ pubkey: string; name: string }>; text?: never; mentions?: never; + pending?: never; }> | Readonly<{ mentions: readonly Readonly<{ pubkey: string; name: string }>[]; text?: never; mention?: never; + pending?: never; + }> + /** Host-only: the terminator typed over an undecided name. Never a suggestion's edit. */ + | Readonly<{ + pending: CompletionPending; + text?: never; + mention?: never; + mentions?: never; }>; export type CompletionSuggestion = Readonly<{ id: string; @@ -267,6 +297,9 @@ export type CompletionResult = Readonly<{ items: readonly CompletionSuggestion[]; /** Provider-verified unique exact match across its uncapped candidate set. */ spaceId?: string | undefined; + /** No `spaceId` yet only because evidence is still arriving; the terminator + * may hold the query as a pending mention instead of typing a literal. */ + spacePending?: CompletionPending | undefined; status?: string; /** Optional explicit recovery. A new query or disposal revokes this action. */ retry?: () => void; diff --git a/src/features/direct-messages/NewMessage.tsx b/src/features/direct-messages/NewMessage.tsx index f27551da87..144c97791e 100644 --- a/src/features/direct-messages/NewMessage.tsx +++ b/src/features/direct-messages/NewMessage.tsx @@ -145,6 +145,7 @@ export function NewMessage({ savedRecipients(scope, session.viewer), ); const recipientNames = recipients.map((person) => person.name).join(", "); + const roster = useMemo(() => ({ recipients }), [recipients]); const [legacy, setLegacy] = useState(() => savedLegacy(scope, session.viewer), ); @@ -461,7 +462,7 @@ export function NewMessage({ )} - + { input.focus(); input.setSelectionRange(text.length, text.length); + // jsdom's focus() collapses the selection onto the focused element after + // its focus events, where a browser keeps the caret the editor placed, and + // reports that in a later task; ProseMirror puts its caret back on that + // selectionchange. Deliver it now: an empty draft moves no selection above, + // so a key typed before the task would land outside the paragraph. + document.dispatchEvent(new Event("selectionchange")); }); return { input, @@ -149,6 +155,16 @@ function mount( }; } +it("types the first character into the empty paragraph with no task between focus and the key", async () => { + const h = mount(); + // The ordering a loaded runner produces: user-event's wait before the key + // fires before jsdom's queued selectionchange. With no delay, nothing runs + // between mount and the keystroke at all. + await userEvent.setup({ delay: null }).keyboard("`"); + expect(h.input.innerHTML).toBe("

`

"); + expect(h.draft().text).toBe("`"); +}); + it("refuses a closing-colon conversion when its literal undo source exceeds the limit", () => { const h = mount(":-1", { maxLength: 3 }); act(() => @@ -3131,6 +3147,348 @@ it("rejects a batch that exceeds message length without partial insertion", () = expect(h.draft()).toEqual(before); }); +// Pending mentions: Space committed a name before the chooser could decide. +// The host resolves the chip later; the editor owns its undo and persistence. +const example = { pubkey: "a".repeat(64), name: "Example Agent" }; +function pendingChip(h: ReturnType) { + return h.input.querySelector('[data-state="loading"]'); +} +it("inserts a pending chip as one undo step that restores the typed query", () => { + const h = mount("hi @example"); + let id: string | false = false; + act(() => { + id = h.input.insertPendingMention("example", { start: 3, end: 11 }); + }); + expect(id).toMatch(/^pending-/); + expect(h.input).toHaveValue("hi @example "); + expect([h.input.selectionStart, h.input.selectionEnd]).toEqual([12, 12]); + expect(h.draft().recipients).toEqual([]); + expect(h.draft().pending).toEqual([ + { id, name: "example", start: 3, end: 11 }, + ]); + expect(pendingChip(h)).toHaveTextContent("@example"); + act(() => h.input.undo(false)); + expect(h.input).toHaveValue("hi @example"); + expect(h.draft().pending).toBeUndefined(); + act(() => h.input.undo(true)); + expect(h.draft().pending?.map((item) => item.id)).toEqual([id]); +}); +it("refuses a pending chip away from the caret, over other text, or in code", () => { + const h = mount("hi @example"); + act(() => h.input.setSelectionRange(5, 5)); + act(() => + expect(h.input.insertPendingMention("example", { start: 3, end: 11 })).toBe( + false, + ), + ); + act(() => h.input.setSelectionRange(11, 11)); + act(() => + expect(h.input.insertPendingMention("other", { start: 3, end: 11 })).toBe( + false, + ), + ); + act(() => h.input.toggleFormat("code_block")); + act(() => + expect(h.input.insertPendingMention("example", { start: 3, end: 11 })).toBe( + false, + ), + ); + expect(h.input).toHaveValue("hi @example"); + expect(h.draft().pending).toBeUndefined(); +}); +it("settles a pending chip into its recipient's current name, keeping the caret and the typed text as the undo target", async () => { + const h = mount("hi @example"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 3, end: 11 }) || ""; + }); + await h.user.type(h.input, "there"); + expect(h.input).toHaveValue("hi @example there"); + act(() => h.input.setSelectionRange(14, 14)); + act(() => expect(h.input.settlePendingMention(id, example)).toBe(true)); + expect(h.input).toHaveValue("hi @Example Agent there"); + expect(h.draft().pending).toBeUndefined(); + expect(h.draft().recipients).toEqual([{ ...example, start: 3, end: 17 }]); + // The caret kept its place in the prose after the chip. + expect([h.input.selectionStart, h.input.selectionEnd]).toEqual([20, 20]); + expect(pendingChip(h)).toBeNull(); + act(() => expect(h.input.settlePendingMention(id, example)).toBe(false)); + act(() => h.input.undo(false)); + expect(h.input).toHaveValue("hi @Example Agent "); + act(() => h.input.undo(false)); + expect(h.input).toHaveValue("hi @example"); + expect(h.draft().recipients).toEqual([]); +}); +it("settles a pending chip back into plain text while the editor is read-only", () => { + const h = mount("@example"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 0, end: 8 }) || ""; + }); + act(() => { + h.input.readOnly = true; + }); + act(() => expect(h.input.settlePendingMention(id, null)).toBe(true)); + expect(h.input).toHaveValue("@example "); + expect(h.draft().pending).toBeUndefined(); + expect(h.draft().recipients).toEqual([]); + expect(h.input.querySelector("[data-source]")).toBeNull(); +}); +it("holds a settle that lands during a composition until the composition ends", async () => { + const h = mount("hi @example"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 3, end: 11 }) || ""; + }); + act(() => fireEvent.compositionStart(h.input)); + let held: boolean | Promise = false; + act(() => { + held = h.input.settlePendingMention(id, example); + }); + expect(held).toBeInstanceOf(Promise); + expect(h.input).toHaveValue("hi @example "); + expect(pendingChip(h)).toHaveTextContent("@example"); + expect(h.draft().recipients).toEqual([]); + await act(async () => fireEvent.compositionEnd(h.input)); + await expect(held).resolves.toBe(true); + expect(h.input).toHaveValue("hi @Example Agent "); + expect(h.draft().pending).toBeUndefined(); + expect(h.draft().recipients).toEqual([{ ...example, start: 3, end: 17 }]); + expect(pendingChip(h)).toBeNull(); +}); +it("applies a held settle after the composition's final text has been read", async () => { + const h = mount("hi @example"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 3, end: 11 }) || ""; + }); + act(() => fireEvent.compositionStart(h.input)); + let held: boolean | Promise = false; + act(() => { + held = h.input.settlePendingMention(id, null); + }); + const after = h.input.querySelector("[data-source]")?.nextSibling; + if (!(after instanceof Text)) throw new Error("No text after the chip"); + await act(async () => { + // The IME commits its text and ends the composition in one task; + // ProseMirror reads the record only after compositionend. + after.nodeValue = " ab"; + fireEvent.compositionEnd(h.input); + }); + await expect(held).resolves.toBe(true); + expect(h.input).toHaveValue("hi @example ab"); + expect(h.draft().pending).toBeUndefined(); + expect(pendingChip(h)).toBeNull(); +}); +it("answers a held settle false when its chip is gone by the time the composition ends", async () => { + const h = mount("hi @example"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 3, end: 11 }) || ""; + }); + act(() => fireEvent.compositionStart(h.input)); + let held: boolean | Promise = false; + act(() => { + held = h.input.settlePendingMention(id, example); + }); + await act(async () => { + h.input.querySelector("[data-source]")?.remove(); + fireEvent.compositionEnd(h.input); + }); + await expect(held).resolves.toBe(false); + expect(h.input).toHaveValue("hi "); + expect(h.draft().pending).toBeUndefined(); + expect(h.draft().recipients).toEqual([]); +}); +it("keeps the typed text when the recipient's name would overrun the limit", () => { + const h = mount("@example", { maxLength: 12 }); + let id = ""; + act(() => { + id = h.input.insertPendingMention("example", { start: 0, end: 8 }) || ""; + }); + act(() => expect(h.input.settlePendingMention(id, example)).toBe(true)); + expect(h.input).toHaveValue("@example "); + expect(h.draft().recipients).toEqual([]); +}); +const miraExample = { pubkey: "a".repeat(64), name: "Mira Example" }; +it("extends a pending chip over the typed words that complete a longer name, keeping the caret and the typed text as the undo target", async () => { + const h = mount("hi @mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 3, end: 8 }) || ""; + }); + await h.user.type(h.input, "Example there"); + expect(h.input).toHaveValue("hi @mira Example there"); + act(() => expect(h.input.extendPendingMention(id, " Example")).toBe(true)); + expect(h.input).toHaveValue("hi @mira Example there"); + expect(h.draft().pending).toEqual([ + { id, name: "mira Example", start: 3, end: 16 }, + ]); + expect(pendingChip(h)).toHaveTextContent("@mira Example"); + expect(h.input.querySelectorAll("[data-source]")).toHaveLength(1); + // The caret kept its place in the prose after the chip. + expect([h.input.selectionStart, h.input.selectionEnd]).toEqual([22, 22]); + act(() => expect(h.input.settlePendingMention(id, miraExample)).toBe(true)); + expect(h.input).toHaveValue("hi @Mira Example there"); + expect(h.draft().pending).toBeUndefined(); + expect(h.draft().recipients).toEqual([{ ...miraExample, start: 3, end: 16 }]); + // Undo takes the words typed after the chip first, then the chip's own + // typed query, as it does for a chip that settled unextended. + act(() => h.input.undo(false)); + expect(h.input).toHaveValue("hi @Mira Example"); + expect(h.draft().recipients).toEqual([{ ...miraExample, start: 3, end: 16 }]); + act(() => h.input.undo(false)); + expect(h.input).toHaveValue("hi @mira"); + expect(h.draft().recipients).toEqual([]); + expect(h.draft().pending).toBeUndefined(); + act(() => h.input.undo(true)); + expect(h.input).toHaveValue("hi @Mira Example"); +}); +it("refuses to extend a pending chip over text that no longer begins with the words, or that is gone", async () => { + const h = mount("@mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 0, end: 5 }) || ""; + }); + await h.user.type(h.input, "Exam"); + act(() => expect(h.input.extendPendingMention(id, " Example")).toBe(false)); + act(() => expect(h.input.extendPendingMention(id, "")).toBe(false)); + act(() => expect(h.input.extendPendingMention("other", " Exam")).toBe(false)); + expect(h.input).toHaveValue("@mira Exam"); + expect(h.draft().pending).toEqual([{ id, name: "mira", start: 0, end: 5 }]); + act(() => expect(h.input.settlePendingMention(id, null)).toBe(true)); + act(() => expect(h.input.extendPendingMention(id, " Exam")).toBe(false)); + expect(h.input).toHaveValue("@mira Exam"); +}); +it("extends a pending chip while the editor is read-only, and settling it to text restores the typed words", async () => { + const h = mount("@mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 0, end: 5 }) || ""; + }); + await h.user.type(h.input, "Example"); + act(() => { + h.input.readOnly = true; + }); + act(() => expect(h.input.extendPendingMention(id, " Example")).toBe(true)); + expect(h.draft().pending).toEqual([ + { id, name: "mira Example", start: 0, end: 13 }, + ]); + act(() => expect(h.input.settlePendingMention(id, null)).toBe(true)); + expect(h.input).toHaveValue("@mira Example"); + expect(h.draft().pending).toBeUndefined(); + expect(h.input.querySelector("[data-source]")).toBeNull(); +}); +it("holds an extension that lands during a composition until the composition ends", async () => { + const h = mount("@mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 0, end: 5 }) || ""; + }); + await h.user.type(h.input, "Example"); + act(() => fireEvent.compositionStart(h.input)); + let held: boolean | Promise = false; + act(() => { + held = h.input.extendPendingMention(id, " Example"); + }); + expect(held).toBeInstanceOf(Promise); + expect(h.draft().pending).toEqual([{ id, name: "mira", start: 0, end: 5 }]); + await act(async () => fireEvent.compositionEnd(h.input)); + await expect(held).resolves.toBe(true); + expect(h.draft().pending).toEqual([ + { id, name: "mira Example", start: 0, end: 13 }, + ]); + expect(pendingChip(h)).toHaveTextContent("@mira Example"); +}); +it("answers a held extension false when the composition committed name characters after the words", async () => { + const h = mount("@mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 0, end: 5 }) || ""; + }); + await h.user.type(h.input, "Example"); + act(() => fireEvent.compositionStart(h.input)); + let held: boolean | Promise = false; + act(() => { + held = h.input.extendPendingMention(id, " Example"); + }); + const after = h.input.querySelector("[data-source]")?.nextSibling; + if (!(after instanceof Text)) throw new Error("No text after the chip"); + await act(async () => { + // The IME commits a longer word than the host read. + after.nodeValue = " Examples"; + fireEvent.compositionEnd(h.input); + }); + await expect(held).resolves.toBe(false); + expect(h.input).toHaveValue("@mira Examples"); + expect(h.draft().pending).toEqual([{ id, name: "mira", start: 0, end: 5 }]); +}); +it("returns a pending chip to its typed query when a name character is typed right after it", async () => { + const h = mount("@mira"); + let id = ""; + act(() => { + id = h.input.insertPendingMention("mira", { start: 0, end: 5 }) || ""; + }); + await h.user.keyboard("{Backspace}nda"); + expect(h.input).toHaveValue("@miranda"); + expect(h.draft().pending).toBeUndefined(); + expect(h.input.querySelector("[data-source]")).toBeNull(); + act(() => expect(h.input.settlePendingMention(id, miraExample)).toBe(false)); + expect(h.draft().recipients).toEqual([]); + act(() => h.input.undo(false)); + expect(h.draft().pending?.map((item) => item.id)).toEqual([id]); + // Punctuation is a name boundary and keeps the chip, as for a recipient. + const p = mount("@mira"); + act(() => { + p.input.insertPendingMention("mira", { start: 0, end: 5 }); + }); + await p.user.keyboard("{Backspace},"); + expect(p.input).toHaveValue("@mira,"); + expect(p.draft().pending).toHaveLength(1); +}); +it("leaves a pending chip to the identity link that adopts it, without restoring its typed query", async () => { + const h = mount("[@honey", { links: true }); + let id = ""; + act(() => { + id = h.input.insertPendingMention("honey", { start: 1, end: 7 }) || ""; + }); + await h.user.keyboard("{Backspace}"); + const link = `[@honeysuckle](${profileTarget(example.pubkey)})`; + paste(h.input, link.slice(7)); + expect(h.markdown()).toBe(link); + expect(h.input.querySelector(`[data-source="${link}"]`)).not.toBeNull(); + expect(h.draft().pending).toBeUndefined(); + act(() => expect(h.input.settlePendingMention(id, example)).toBe(false)); + expect(h.draft().recipients).toEqual([]); +}); +it("deletes a pending chip with Backspace like any chip, emptying the projection", () => { + const h = mount("@example"); + act(() => h.input.insertPendingMention("example", { start: 0, end: 8 })); + act(() => h.input.setSelectionRange(8, 8)); + act(() => { + fireEvent.keyDown(h.input, { key: "Backspace" }); + }); + expect(h.input).toHaveValue("@example "); + expect([h.input.selectionStart, h.input.selectionEnd]).toEqual([0, 8]); + act(() => { + fireEvent.keyDown(h.input, { key: "Backspace" }); + }); + expect(h.input).toHaveValue(" "); + expect(h.draft().pending).toBeUndefined(); +}); +it("restores a persisted pending chip as plain text and copies it as its text", () => { + const h = mount("@example"); + act(() => h.input.insertPendingMention("example", { start: 0, end: 8 })); + act(() => h.input.setSelectionRange(0, 9)); + expect(clipboard(h.input, "copy").text).toBe("@example "); + const saved = JSON.parse(JSON.stringify(h.draft())); + expect(saved.pending).toHaveLength(1); + const restored = mount(saved); + expect(restored.input).toHaveValue("@example "); + expect(restored.draft().pending).toBeUndefined(); + expect(restored.input.querySelector("[data-source]")).toBeNull(); +}); + // Clipboard: composer copy/cut write both flavors; paste reads this app's own // HTML back through selection-copy.ts and promotes accepted identity links. const mic = "c".repeat(64); diff --git a/src/features/messages/EditableInput.tsx b/src/features/messages/EditableInput.tsx index 2ef186fc2c..ba67861e2a 100644 --- a/src/features/messages/EditableInput.tsx +++ b/src/features/messages/EditableInput.tsx @@ -91,6 +91,7 @@ import { macWebKit, webKit } from "./mac-webkit"; import { composerLinkUrl } from "./composer-link"; import { messageLinkParts } from "./message-link-parts"; import { updatePlainLinks, type PlainLink } from "./composer-link-edit"; +import { InlineChip } from "../../shared/design-system/ui/InlineChip"; import "prosemirror-view/style/prosemirror.css"; import styles from "./EditableInput.module.css"; @@ -354,6 +355,9 @@ function crossTokenLine( } return false; } +type PendingChip = { id: string; name: string }; +// Pending chip ids outlive an editor: a host keeps its resolvers across remounts. +let pendingChips = 0; export type EditableInputProps = Omit< HTMLAttributes, "onChange" | "onInput" @@ -423,6 +427,7 @@ export function EditableInput({ { source: string; resource: ComposerResource | null; + pending: PendingChip | null; position(): number | undefined; } >(), @@ -430,6 +435,11 @@ export function EditableInput({ const [, refresh] = useState(0); const scrollAfterTokens = useRef(false); const composing = useRef(false); + /** Pending-chip settles and extensions that landed during an IME + * composition, applied when it ends. */ + const heldSettles = useRef< + { apply(): boolean; resolve(applied: boolean): void }[] + >([]); const pointing = useRef(false); const nativeEditing = useRef(false); const plain = useRef([]); @@ -437,6 +447,8 @@ export function EditableInput({ const emitted = useRef(draft); const api = useRef<{ sync(draft: MentionDraft, reset?: boolean): void; + /** Applies the settles held through the composition that has just ended. */ + settleHeld(): void; } | null>(null); useLayoutEffect(() => { @@ -594,9 +606,10 @@ export function EditableInput({ ), ); } - // Keep semantic recipient/resource atoms, but demote presentation-only - // tokens when their renderer disappears so the source is editable again. - // Mention-boundary edits also remove invalidated recipient provenance. + // Keep semantic recipient/resource/pending-mention atoms, but demote + // presentation-only tokens when their renderer disappears so the source + // is editable again. Mention-boundary edits also remove invalidated + // recipient provenance, and return a pending mention to its typed query. for (const token of next.tokens) { if ( token.node.attrs.recipient @@ -605,6 +618,8 @@ export function EditableInput({ item.start === token.start && item.end === token.end, ) : token.node.attrs.resource || + (token.node.attrs.pending && + mentionBoundary(next.draft.text, token.end)) || decorations.some( // A larger decoration has already adopted any tokens inside it. (item) => item.start <= token.start && item.end >= token.end, @@ -939,6 +954,153 @@ export function EditableInput({ editor.dispatch(tr); return editor.state !== before; }; + /** The typed `@name` becomes one atom the host resolves later: one undo + * step, like a selected mention, and the caret moves past its space. */ + const insertPendingMention = ( + name: string, + range: { start: number; end: number }, + ): string | false => { + if (!editable() || composing.current || editor.composing) return false; + const source = projection(); + const from = source.position(range.start), + to = source.position(range.end, -1); + const $from = editor.state.doc.resolve(from); + if ( + !editor.state.selection.empty || + source.source(editor.state.selection.from) !== range.end || + source.draft.text.slice(range.start, range.end) !== `@${name}` || + $from.parent.type.spec.code + ) + return false; + const marks = $from.parent.type + .allowedMarks(editor.state.storedMarks ?? $from.marks()) + .filter((mark) => mark.type !== composerSchema.marks.recipient); + const id = `pending-${++pendingChips}`; + const tr = closeHistory(editor.state.tr).replaceWith( + from, + to, + Fragment.fromArray([ + composerSchema.nodes.token.create( + { source: `@${name}`, pending: { id, name }, editAsText: false }, + null, + marks, + ), + composerSchema.text(" ", marks), + ]), + ); + tr.setSelection( + Selection.near(tr.doc.resolve(tr.mapping.map(to, 1)), -1), + ); + if ( + composerMarkdown(projectComposerDocument(tr.doc).draft).length > + current.current.maxLength + ) + return false; + const before = editor.state; + editor.dispatch(tr.scrollIntoView()); + if (editor.state === before) return false; + separateHistory = true; + editor.focus(); + return id; + }; + const pendingToken = (id: string) => + projection().tokens.find((token) => token.node.attrs.pending?.id === id); + /** Settling is not an edit: it happens outside history, while sending too, + * and a later undo still restores the typed text through it. */ + const settlePendingMention = ( + id: string, + recipient: MentionRecipient | null, + ): boolean | Promise => { + if (!pendingToken(id)) return false; + // A document change under a composition can drop or commit it early, + // and this runs from a promise at any time. Hold it until compositionend + // and answer with the replay's outcome, so the host does not treat a + // chip that is still in the draft as settled. Only React's flag decides: + // the event that clears it is the one that replays, while ProseMirror's + // `composing` stays set after a composition that ended while the view + // was not editable (a Send under the IME), and a settle held on it + // would wait for a compositionend that never comes. + if (composing.current) return hold(() => applySettle(id, recipient)); + return applySettle(id, recipient); + }; + const hold = (apply: () => boolean) => + new Promise((resolve) => { + heldSettles.current.push({ apply, resolve }); + }); + const applySettle = (id: string, recipient: MentionRecipient | null) => { + const token = pendingToken(id); + if (!token) return false; + const convert = (person: MentionRecipient | null) => + editor.state.tr + .replaceWith( + token.from, + token.to, + person + ? composerSchema.nodes.token.create( + { source: `@${person.name}`, recipient: person }, + null, + token.node.marks, + ) + : composerSchema.text(token.node.attrs.source, token.node.marks), + ) + .setMeta("composer-normalized", true) + .setMeta("addToHistory", false); + let tr = convert(recipient); + // A longer current name can overrun the limit; the typed text then stands. + if ( + recipient && + composerMarkdown(projectComposerDocument(tr.doc).draft).length > + current.current.maxLength + ) + tr = convert(null); + editor.dispatch(tr); + return true; + }; + /** The words typed after a pending chip that complete a longer known name + * join the chip, which the host then resolves under the whole name. Like a + * settle: outside history, while sending too, held under a composition. */ + const extendPendingMention = ( + id: string, + remainder: string, + ): boolean | Promise => { + if (!remainder || !pendingToken(id)) return false; + if (composing.current) return hold(() => applyExtend(id, remainder)); + return applyExtend(id, remainder); + }; + const applyExtend = (id: string, remainder: string) => { + const token = pendingToken(id); + if (!token) return false; + const pending: PendingChip = token.node.attrs.pending; + const after = editor.state.doc.resolve(token.to).nodeAfter; + // The host read the words at a name boundary; they, or what follows + // them, may have changed since (an IME can commit ` Examples`). + if ( + !after?.isText || + !after.text?.startsWith(remainder) || + !mentionBoundary(projection().draft.text, token.end + remainder.length) + ) + return false; + const name = pending.name + remainder; + // Two steps, so the typed text stays the undo target: the chip changes + // in place (one atom for one atom, as a settle does) and the words after + // it leave separately. + editor.dispatch( + editor.state.tr + .replaceWith( + token.from, + token.to, + composerSchema.nodes.token.create( + { source: `@${name}`, pending: { id, name }, editAsText: false }, + null, + token.node.marks, + ), + ) + .delete(token.to, token.to + remainder.length) + .setMeta("composer-normalized", true) + .setMeta("addToHistory", false), + ); + return true; + }; /** True, or the reason shown by the host. The link must survive its context. */ const insertResource = (value: ComposerResource): true | string => { if (!editable() || composing.current || editor.composing) @@ -1558,6 +1720,7 @@ export function EditableInput({ hosts.current.set(dom, { source: node.attrs.source, resource: node.attrs.resource, + pending: node.attrs.pending, position: getPos, }); return { @@ -2044,6 +2207,18 @@ export function EditableInput({ setSelectionRange: { configurable: true, value: setRange }, insertText: { configurable: true, value: insert }, replaceRecipients: { configurable: true, value: replaceRecipients }, + insertPendingMention: { + configurable: true, + value: insertPendingMention, + }, + settlePendingMention: { + configurable: true, + value: settlePendingMention, + }, + extendPendingMention: { + configurable: true, + value: extendPendingMention, + }, insertResource: { configurable: true, value: insertResource }, toggleFormat: { configurable: true, value: toggleFormat }, insertLineBreak: { @@ -2115,6 +2290,14 @@ export function EditableInput({ }, }); api.current = { + settleHeld() { + // ProseMirror skips its own compositionend handler while the view is + // not editable, as during a send, and leaves its `composing` set until + // the next click. The composition has ended all the same, so the + // replay, like the hold above, does not consult that flag. + for (const held of heldSettles.current.splice(0)) + held.resolve(held.apply()); + }, sync(next, reset = false) { if ( !reset && @@ -2151,6 +2334,7 @@ export function EditableInput({ ref.current = null; view.current = null; api.current = null; + for (const held of heldSettles.current.splice(0)) held.resolve(false); editor.destroy(); }; }, [ref]); @@ -2259,6 +2443,11 @@ export function EditableInput({ }} onCompositionEnd={(event) => { composing.current = false; + // ProseMirror reads the composition's final DOM records in a + // microtask queued by its own compositionend handler, which ran + // first; a settle dispatched before that flush would rewrite the + // text node those records point at and lose the committed text. + queueMicrotask(() => api.current?.settleHeld()); events.onCompositionEnd?.(event as never); }} /> @@ -2272,8 +2461,18 @@ export function EditableInput({ item.end === (source ?? 0) + host.source.length, ); return createPortal( - host.resource ? ( - // Host-owned artwork: restore never depends on the providing plugin. + // Host-owned artwork: restore never depends on the providing plugin. + host.pending ? ( + + ) : host.resource ? ( <> Resource: {host.resource.label} diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index 770799ad92..329aa0abba 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -30,16 +30,22 @@ import { waitFor, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; -import { useLayoutEffect } from "react"; +import { useEffect, useLayoutEffect } from "react"; import type { Contribution } from "../../plugins/contributions"; import type { ComposerCompletion, ComposerCompletionProps, ComposerTool, ComposerToolProps, + CompletionPending, + CompletionResolution, CompletionResult, InlineRenderer, } from "../conversation/contracts"; +import { MENTION_RESOLVE_TIMEOUT_MS } from "../conversation/completion"; +import { mentionQuery } from "../../bundled/mentions/mention-query"; +import { MentionCompletion } from "../../bundled/mentions/MentionCompletion"; +import type { MentionRecipient } from "./mention-draft"; import type { AgentLibrarySnapshot } from "../agents/library"; import { createAgentChoices } from "../agents/choices"; import type { LocalAgents } from "../agents/local"; @@ -925,6 +931,202 @@ it.each(["click", "Enter", "Tab", " "])( }, ); +it("selects the exact match on Space from a publication React has not rendered yet", () => { + const h = mount(); + const input = h.input(); + input.focus(); + h.fill("!search"); + const publish = h.completionRequests.at(-1); + if (!publish) throw new Error("No observed completion request"); + const chosen = { id: "chosen", label: "Chosen", edit: { text: "chosen" } }; + act(() => { + publish({ items: [chosen] }); + }); + expect(screen.getByRole("option", { name: "Chosen" })).toBeVisible(); + act(() => { + // The provider decides Space on the keystroke that completed the name, + // and a fast Space arrives before React commits that publication. + publish({ items: [chosen], spaceId: chosen.id }); + expect(fireEvent.keyDown(input, { key: " " })).toBe(false); + }); + expect(input).toHaveValue("chosen "); + expect(screen.queryByRole("listbox")).not.toBeInTheDocument(); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +/** The real `@` syntax with a provider that answers each query from its mount + * effect, as the bundled providers do: one row named by the query, the exact + * name decided on Space, and a settled empty result once a Space is typed. + * `mounted` runs in that effect before the publication, after the host has + * committed what it shows for the query on its own. */ +function mountTypeahead(mounted?: (query: string) => void) { + function Completion({ query, publish }: ComposerCompletionProps) { + useEffect(() => { + mounted?.(query.query); + const withdraw = publish( + query.query.includes(" ") + ? { items: [] } + : { + items: [ + { + id: first.pubkey, + label: `Honey ${query.query}`, + edit: { mention: first }, + }, + ], + ...(query.query === "honey" ? { spaceId: first.pubkey } : {}), + }, + ); + return () => { + if (withdraw) withdraw(); + }; + }, [query.query, publish]); + return null; + } + const completions: readonly Contribution[] = [ + { + id: "mentions", + key: "buzz.mentions/mentions", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: Completion, + }, + ]; + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const h = mount({ + extensions: { + tools: none, + inline: none, + completions: { snapshot: () => completions, subscribe: () => () => {} }, + }, + }); + return { + ...h, + /** The popup's row labels, or null while the popup is not in the document. */ + rows() { + const popup = document.querySelector( + 'section[aria-label="Mention suggestions"]', + ); + return popup + ? [...popup.querySelectorAll('[role="option"]')].map( + (row) => row.textContent, + ) + : null; + }, + }; +} + +it("keeps the popup up through each keystroke that continues an open @ query", async () => { + const h = mountTypeahead(); + const input = h.input(); + input.focus(); + h.fill("@h"); + expect(h.rows()).toEqual(["Honey h"]); + const env = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean | undefined }; + const previous = env.IS_REACT_ACT_ENVIRONMENT; + // Each delivery of DOM changes is a point at which the browser may paint. + // The per-query owner remounts on every keystroke, so its popup section is + // a new node each time; a delivery whose records removed a section without + // adding its replacement left the popup absent at a paint opportunity. + const popup = 'section[aria-label$=" suggestions"]'; + const holds = (nodes: NodeList) => + [...nodes].some( + (node) => + node instanceof Element && + (node.matches(popup) || !!node.querySelector(popup)), + ); + const deliveries: { removed: boolean; added: boolean }[] = []; + const observer = new MutationObserver((records) => + deliveries.push({ + removed: records.some((record) => holds(record.removedNodes)), + added: records.some((record) => holds(record.addedNodes)), + }), + ); + observer.observe(document.body, { childList: true, subtree: true }); + try { + env.IS_REACT_ACT_ENVIRONMENT = false; + for (const text of ["@ho", "@hon", "@hone", "@honey"]) { + // The draft changes during a discrete event, as under a keystroke, so + // React commits the new observation in a microtask rather than under + // act; the provider's rows for it must be in the document by the end + // of that task, with no delivery in between showing no popup. + input.addEventListener( + "select", + () => { + input.value = text; + input.setSelectionRange(text.length, text.length); + }, + { once: true }, + ); + input.dispatchEvent(new Event("select")); + await Promise.resolve(); + expect(h.rows()).toEqual([`Honey ${text.slice(1)}`]); + // The observer's own microtask was queued during React's. + await Promise.resolve(); + } + } finally { + env.IS_REACT_ACT_ENVIRONMENT = previous; + observer.disconnect(); + await act(async () => {}); + } + expect(deliveries.filter(({ removed, added }) => removed && !added)).toEqual( + [], + ); + expect(deliveries.filter(({ removed }) => removed)).toHaveLength(4); + expect(h.rows()).toEqual(["Honey honey"]); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("reopens a query at the same offset after Escape with no carried rows", () => { + // What the host showed for each query before its provider published, once + // per query: StrictMode runs the provider's mount effect twice. + const shown: [string, string[] | null][] = []; + const h = mountTypeahead((query) => { + if (shown.at(-1)?.[0] !== query) shown.push([query, h.rows()]); + }); + const input = h.input(); + input.focus(); + h.fill("@hon"); + h.fill("@hone"); + expect(h.rows()).toEqual(["Honey hone"]); + fireEvent.keyDown(input, { key: "Escape" }); + expect(h.rows()).toBeNull(); + h.fill("@honey"); + expect(h.rows()).toEqual(["Honey honey"]); + expect(shown).toEqual([ + ["hon", null], + // A continuing keystroke carries the previous query's rows. + ["hone", ["Honey hon"]], + // A query reopened after the popup closed starts empty, as a first one. + ["honey", null], + ]); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("still closes the popup when the query closes, on Escape, and on a Space that selects", () => { + const h = mountTypeahead(); + const input = h.input(); + input.focus(); + h.fill("@hon"); + expect(h.rows()).toEqual(["Honey hon"]); + // A settled empty result for the continued query: `@hon ` is prose. + h.fill("@hon "); + expect(h.rows()).toBeNull(); + h.fill("@hone"); + expect(h.rows()).toEqual(["Honey hone"]); + fireEvent.keyDown(input, { key: "Escape" }); + expect(h.rows()).toBeNull(); + h.fill("@honey"); + expect(h.rows()).toEqual(["Honey honey"]); + expect(fireEvent.keyDown(input, { key: " " })).toBe(false); + expect(input).toHaveValue("@Honey "); + expect(h.rows()).toBeNull(); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + it.each(["click", "Enter", "Tab"])( "rejects withdrawn or ineligible refreshed completions via %s without sending", (key) => { @@ -10086,3 +10288,1233 @@ it("guards automatic recipient changes using the current draft prose", () => { h.fill(" \n "); act(() => expect(replace?.([second])).toBe(true)); }); + +/** A mention provider the test publishes for, over the real `@` syntax. */ +function mountPendingMention(options: Partial = {}) { + const publishes: ComposerCompletionProps["publish"][] = []; + function Completion({ publish }: ComposerCompletionProps) { + useLayoutEffect(() => { + publishes.push(publish); + }, [publish]); + return null; + } + const completions: readonly Contribution[] = [ + { + id: "mentions", + key: "buzz.mentions/mentions", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: Completion, + }, + ]; + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const h = mount({ + ...options, + extensions: { + tools: none, + inline: none, + completions: { snapshot: () => completions, subscribe: () => () => {} }, + }, + }); + const resolve = vi.fn(); + const publish = () => { + const publish = publishes.at(-1); + if (!publish) throw new Error("No mention query observed"); + return publish; + }; + return { + ...h, + resolve, + /** How many `@` queries the provider has been mounted for. */ + queries: () => publishes.length, + publish, + /** Type `@name`, publish an undecided result for it, then type Space. + * Returns the keydown's default-prevented state inverted: false when held. */ + hold(name: string, outcome: Promise) { + resolve.mockImplementation(() => outcome); + h.fill(`@${name}`); + act(() => { + publish()({ items: [], spacePending: { name, resolve } }); + }); + return fireEvent.keyDown(h.input(), { key: " " }); + }, + chip: (name: string) => + within(h.input()).queryByRole("img", { + name: `Resolving mention ${name}`, + }), + }; +} + +it("holds an undecided name on Space, then settles it into the recipient without reopening the chooser", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + try { + expect(h.hold("honey", outcome.promise)).toBe(false); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toHaveAttribute("aria-busy", "true"); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + expect(h.resolve).toHaveBeenCalledOnce(); + // The chip's `@` is not an open query: no provider mounts for "honey ". + const queries = h.queries(); + await act(async () => outcome.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + expect(h.chip("honey")).toBeNull(); + expect( + within( + screen.getByRole("region", { name: "Explicit mentions" }), + ).getAllByRole("button"), + ).toHaveLength(1); + expect(h.queries()).toBe(queries); + h.submit(); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@Honey ", + [first.pubkey], + [], + ); + } finally { + outcome.resolve(null); + } +}); + +it("leaves Space literal beside a loaded longer member name and selects that name on the next Space", async () => { + // The real chooser over the real `@` syntax, with the directory read held: + // `Mira Example` continues `mira` whatever the read finds, so the first + // Space is prose, not a pending chip, and the query stays open for the word + // that completes the name. + const completions: readonly Contribution[] = [ + { + id: "mentions", + key: "buzz.mentions/mentions", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: MentionCompletion, + }, + ]; + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const h = mount({ + extensions: { + tools: none, + inline: none, + completions: { snapshot: () => completions, subscribe: () => () => {} }, + }, + }); + const people = vi.fn(() => new Promise(() => {})); + h.retarget({ + session: { + ...h.session, + channels: { ...h.session.channels, ensureList: () => {} }, + directMessages: { people }, + } as unknown as RelaySession, + }); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Mira Example" }], + [second.pubkey, { name: "Other Member" }], + ]), + ); + const chip = () => + within(h.input()).queryByRole("img", { name: /^Resolving mention / }); + await h.user.type(h.input(), "@mira"); + // The chooser has published for `mira` once its row shows. + await screen.findByRole("option", { name: /Mira Example/ }); + await h.user.keyboard(" "); + expect(h.input()).toHaveValue("@mira "); + expect(chip()).toBeNull(); + await h.user.keyboard("Example hi"); + expect(h.input()).toHaveValue("@Mira Example hi"); + expect(chip()).toBeNull(); + expect( + within( + screen.getByRole("region", { name: "Explicit mentions" }), + ).getAllByRole("button"), + ).toHaveLength(1); + h.submit(); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@Mira Example hi", + [first.pubkey], + [], + ); +}); + +/** The real chooser over the real `@` syntax with `Mira Example` only in the + * directory and every member named, so the first Space is undecided. Each + * `people` read is held until the test releases it. */ +function mountDirectoryMention( + options: { agents?: Agents; channel?: object; session?: object } = {}, +) { + const completions: readonly Contribution[] = [ + { + id: "mentions", + key: "buzz.mentions/mentions", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: MentionCompletion, + }, + ]; + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const h = mount( + { + extensions: { + tools: none, + inline: none, + completions: { + snapshot: () => completions, + subscribe: () => () => {}, + }, + }, + }, + undefined, + undefined, + options.agents, + ); + const list = { + status: "ready", + channels: h.session.channels + .list() + .channels.map((channel) => ({ ...channel, ...options.channel })), + }; + type Page = { people: readonly MentionRecipient[]; hasMore: boolean }; + const reads: ((page: Page) => void)[] = []; + const people = vi.fn( + (query: string) => + new Promise((resolve) => { + reads.push(resolve); + void query; + }), + ); + h.retarget({ + session: { + ...h.session, + channels: { + ...h.session.channels, + list: () => list, + ensureList: () => {}, + }, + directMessages: { people }, + memberAdditions: { add: vi.fn() }, + ...options.session, + } as unknown as RelaySession, + }); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Other Member" }], + [second.pubkey, { name: "Another Member" }], + ]), + ); + return { + ...h, + people, + /** Answer every read made so far with `page`. */ + release: (page: Page) => + act(async () => { + for (const read of reads.splice(0)) read(page); + }), + chip: (name: string) => + within(h.input()).queryByRole("img", { + name: `Resolving mention ${name}`, + }), + recipients: () => + within( + screen.getByRole("region", { name: "Explicit mentions" }), + ).getAllByRole("button"), + /** Type `@mira`, wait for the chooser to publish, then type Space. */ + async holdMira() { + await h.user.type(h.input(), "@mira"); + await screen.findByText("Searching community…"); + await h.user.keyboard(" "); + expect(h.input()).toHaveValue("@mira "); + expect(this.chip("mira")).toHaveAttribute("aria-busy", "true"); + }, + }; +} +const mira = { pubkey: "c".repeat(64), name: "Mira Example" }; + +/** The owner's agent in another community, as `Agents.joinable()` offers it. */ +function elsewhereAgent(agent: MentionRecipient) { + const snapshot = { agents: [] }; + const types: never[] = []; + const joinable = [ + { key: agent.pubkey, ...agent, relay: "wss://oss.example.com" }, + ]; + return { + snapshot: () => snapshot, + subscribe: () => () => {}, + find: () => undefined, + types: () => types, + joinable: () => joinable, + join: vi.fn(), + } as unknown as Agents; +} + +it("returns a pending mention to prose when a member shares its name with the owner's agent from another community", async () => { + // The chooser counts the other community's agent as a local namesake, so + // Space holds a chip; the resolver must decide over the same set. + const h = mountDirectoryMention({ + agents: elsewhereAgent({ pubkey: "e".repeat(64), name: "Mira" }), + channel: { channelType: "stream" }, + }); + h.setProfiles( + new Map([ + [first.pubkey, { name: "Mira" }], + [second.pubkey, { name: "Another Member" }], + ]), + ); + await h.user.type(h.input(), "@mira"); + await screen.findByRole("option", { name: /In oss\.example\.com/ }); + await h.user.keyboard(" "); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + await waitFor(() => expect(h.chip("mira")).toBeNull()); + expect(h.input()).toHaveValue("@mira "); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + h.submit(); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([]); +}); + +it("settles a pending mention into the owner's agent from another community that is archived here, as Tab would", async () => { + const rocket = { pubkey: "e".repeat(64), name: "Rocket" }; + const archives = { status: "ready" as const, archived: [rocket.pubkey] }; + const h = mountDirectoryMention({ + agents: elsewhereAgent(rocket), + channel: { channelType: "stream" }, + session: { + archives: { + snapshot: () => archives, + subscribe: () => () => {}, + state: (key: string) => + key === rocket.pubkey ? "archived" : "not-archived", + ensure: async () => {}, + }, + }, + }); + // A member's name still unknown keeps the chooser from deciding Space. + h.setProfiles(new Map([[first.pubkey, { name: "Other Member" }]])); + await h.user.type(h.input(), "@rocket"); + await screen.findByRole("option", { name: /Rocket/ }); + await h.user.keyboard(" "); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Rocket ")); + expect(h.chip("rocket")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(await screen.findByRole("dialog")).toHaveTextContent( + /Bring Rocket into/, + ); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("extends a pending chip over the typed words that complete the one longer directory name continuing it", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Example hi"); + expect(h.input()).toHaveValue("@mira Example hi"); + // The resolver reads the page at once; the chooser unmounted with the chip. + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Mira Example hi")); + expect(h.chip("mira")).toBeNull(); + expect(h.chip("mira Example")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + // The extended chip's `@` opened no query. + expect(screen.queryByRole("listbox")).toBeNull(); + h.submit(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "Mira Example is not in this channel.", + ); + fireEvent.click( + screen.getByRole("button", { name: /^(Do nothing|Send anyway)$/ }), + ); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "@Mira Example hi", + [], + ]); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([mira.pubkey]); +}); + +it("keeps the separator typed after the words a pending chip extends over, so later text does not join the name", async () => { + // The page lands while the draft ends at `Example `: the remainder that + // spells the name is `Example`, not `Example ` (`normalizeName` trims, so + // both spell it), and the Space stays outside the chip. + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Example "); + expect(h.input()).toHaveValue("@mira Example "); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Mira Example ")); + expect(h.chip("mira")).toBeNull(); + expect(h.recipients()).toHaveLength(1); + await h.user.keyboard("hi"); + expect(h.input()).toHaveValue("@Mira Example hi"); + expect(h.recipients()).toHaveLength(1); + h.submit(); + fireEvent.click( + screen.getByRole("button", { name: /^(Do nothing|Send anyway)$/ }), + ); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "@Mira Example hi", + [], + ]); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([mira.pubkey]); +}); + +it("selects a longer directory name on the Space that completes it when its page arrived before the first Space", async () => { + // The slow typist: a known longer name declines the first Space outright + // (`continuedMention`), the query stays open, and the second Space selects. + const h = mountDirectoryMention(); + await h.user.type(h.input(), "@mira"); + await waitFor(() => expect(h.people).toHaveBeenCalledTimes(1)); + await h.release({ people: [mira], hasMore: false }); + await screen.findByRole("option", { name: /Mira Example/ }); + await h.user.keyboard(" "); + expect(h.input()).toHaveValue("@mira "); + expect(h.chip("mira")).toBeNull(); + // The complete page for `mira` already answers every query extending it, + // so the name shows on each keystroke with no further read. + await h.user.keyboard("Example hi"); + expect(h.input()).toHaveValue("@Mira Example hi"); + expect(h.recipients()).toHaveLength(1); + expect(h.people).toHaveBeenCalledOnce(); +}); + +it("extends a pending chip to the one longer name the typed words complete when several continue it", async () => { + const h = mountDirectoryMention(); + const examples = { pubkey: "d".repeat(64), name: "Mira Examples" }; + await h.holdMira(); + await h.user.keyboard("Examples hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira, examples], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Mira Examples hi")); + expect(h.recipients()).toHaveLength(1); + h.submit(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "Mira Examples is not in this channel.", + ); + fireEvent.click( + screen.getByRole("button", { name: /^(Do nothing|Send anyway)$/ }), + ); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([examples.pubkey]); +}); + +it("returns the whole span to prose when the name a chip extended into is itself continued by a name the words do not complete", async () => { + // `Mira Example Jr` continues `Mira Example` as that continued `mira`: the + // chip extends once, the second resolution answers `continued` again over + // the same page, and ` hi` completes nothing, so what was typed comes back. + const h = mountDirectoryMention(); + const junior = { pubkey: "d".repeat(64), name: "Mira Example Jr" }; + await h.holdMira(); + await h.user.keyboard("Example hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira, junior], hasMore: false }); + await waitFor(() => expect(h.chip("mira")).toBeNull()); + expect(h.chip("mira Example")).toBeNull(); + expect(h.input()).toHaveValue("@mira Example hi"); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + h.submit(); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@mira Example hi", + [], + [], + ); +}); + +it.each([ + { case: "stop mid-name", typed: "Exam", text: "@mira Exam" }, + { + case: "complete no longer name", + typed: "Other hi", + text: "@mira Other hi", + }, +])( + "returns a pending chip to prose when the words typed after it $case", + async ({ typed, text }) => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard(typed); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.chip("mira")).toBeNull()); + expect(h.input()).toHaveValue(text); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + }, +); + +it("selects a longer directory name on the Space that completes it after its page returned a pending chip to prose mid-word", async () => { + // The page lands while the longer name is still being typed: the chip + // returns to text, the chooser reopens over `mira Exam`, and the complete + // page for `mira` already answers that query, so the name shows at once + // and the Space after `ple` selects it without another read. + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Exam"); + await waitFor(() => expect(h.people).toHaveBeenCalledOnce()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@mira Exam")); + expect( + within(h.input()).queryByRole("img", { name: /^Resolving mention / }), + ).toBeNull(); + await screen.findByRole("option", { name: /Mira Example/ }); + expect(screen.queryByText("Searching community…")).toBeNull(); + await h.user.keyboard("ple "); + expect(h.input()).toHaveValue("@Mira Example "); + expect(h.recipients()).toHaveLength(1); + expect(h.people).toHaveBeenCalledOnce(); + await h.user.keyboard("hi"); + h.submit(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "Mira Example is not in this channel.", + ); + fireEvent.click( + screen.getByRole("button", { name: /^(Do nothing|Send anyway)$/ }), + ); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "@Mira Example hi", + [], + ]); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([mira.pubkey]); +}); + +it("selects a longer directory name on the Space that completes it after its page returned a pending chip to prose before the next word", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await waitFor(() => expect(h.people).toHaveBeenCalledOnce()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.chip("mira")).toBeNull()); + expect(h.input()).toHaveValue("@mira "); + // `mira ` is admitted again: the page for `mira` names what continues it. + await screen.findByRole("option", { name: /Mira Example/ }); + expect(screen.queryByText("Searching community…")).toBeNull(); + await h.user.keyboard("Example "); + expect(h.input()).toHaveValue("@Mira Example "); + expect(h.recipients()).toHaveLength(1); + expect(h.people).toHaveBeenCalledOnce(); +}); + +it("returns a pending chip to prose when the typed words changed under a composition before it could extend", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Example hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + act(() => fireEvent.compositionStart(h.input())); + await h.release({ people: [mira], hasMore: false }); + // The extension is held until the composition ends. + expect(h.chip("mira")).toHaveAttribute("aria-busy", "true"); + const after = h.input().querySelector("[data-source]")?.nextSibling; + if (!(after instanceof Text)) throw new Error("No text after the chip"); + await act(async () => { + // The IME commits a different word and ends the composition in one task. + after.nodeValue = " Exemplar hi"; + fireEvent.compositionEnd(h.input()); + }); + await waitFor(() => expect(h.chip("mira")).toBeNull()); + expect(h.input()).toHaveValue("@mira Exemplar hi"); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); +}); + +it("a send while a chip is pending waits through its extension, then asks about the outside recipient", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Example hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + h.submit(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(), + ); + expect(h.messages.send).not.toHaveBeenCalled(); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => + expect(screen.getByRole("dialog")).toHaveTextContent( + "Mira Example is not in this channel.", + ), + ); + fireEvent.click( + screen.getByRole("button", { name: /^(Do nothing|Send anyway)$/ }), + ); + await waitFor(() => expect(h.messages.send).toHaveBeenCalledOnce()); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "@Mira Example hi", + [], + ]); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([mira.pubkey]); +}); + +it("a send while a chip is pending says its name could not be confirmed when the search finds namesakes", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + h.submit(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(), + ); + await h.release({ + people: [ + { pubkey: "c".repeat(64), name: "Mira" }, + { pubkey: "d".repeat(64), name: "Mira" }, + ], + hasMore: false, + }); + // Two people answer to the name, so neither is tagged, and the notice does + // not claim the name matched no one. + await waitFor(() => + expect(screen.getByRole("alert")).toHaveTextContent( + "@mira could not be confirmed as one person or agent. Send again to send it as text.", + ), + ); + expect(h.chip("mira")).toBeNull(); + expect(h.input()).toHaveValue("@mira hi"); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("returns a chip to prose at the resolver's bound when the longer name it extended into never settles, and keeps the draft for a second send", async () => { + // A scripted provider whose chained resolve does I/O that never answers: + // the host holds it to the bound the first resolution gets. + const h = mountPendingMention(); + const outcome = deferred(); + const chained = vi.fn( + () => new Promise(() => {}), + ); + expect(h.hold("mira", outcome.promise)).toBe(false); + await h.user.keyboard("Example hi"); + expect(h.input()).toHaveValue("@mira Example hi"); + h.submit(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(), + ); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await act(async () => + outcome.resolve({ + continued: [{ name: "Mira Example", resolve: chained }], + }), + ); + expect(h.chip("mira Example")).toHaveAttribute("aria-busy", "true"); + expect(chained).toHaveBeenCalledOnce(); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => + vi.advanceTimersByTime(MENTION_RESOLVE_TIMEOUT_MS - 1), + ); + expect(h.chip("mira Example")).toHaveAttribute("aria-busy", "true"); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + // The chip returned to prose; the waiting send keeps the draft and says + // which name it is, under the name the chip had when it settled. + expect(h.chip("mira Example")).toBeNull(); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "@mira Example could not be confirmed as one person or agent. Send again to send it as text.", + ); + expect(h.input()).toHaveValue("@mira Example hi"); + expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled(); + } finally { + vi.useRealTimers(); + } + h.submit(); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@mira Example hi", + [], + [], + ), + ); + expect(h.input()).toHaveValue(""); +}); + +it("returns a chip to prose at the bound when a provider's first resolution never settles, and keeps the draft for a second send", async () => { + // The contract does not ask a provider to time its resolution; the host + // holds the first link to the same bound as each longer name. + const h = mountPendingMention(); + // The bound starts with the resolution, so the clock is faked before Space. + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + expect(h.hold("mira", new Promise(() => {}))).toBe(false); + expect(h.resolve).toHaveBeenCalledOnce(); + const signal = h.resolve.mock.calls[0]?.[0]; + // Whether the chip was still pending when the provider was told to stop: + // the bound aborts it, not the chip leaving the draft afterwards. + let pendingAtAbort: boolean | undefined; + signal?.addEventListener("abort", () => { + pendingAtAbort = h.chip("mira") !== null; + }); + expect(h.chip("mira")).toHaveAttribute("aria-busy", "true"); + await act(async () => h.submit()); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + await act(async () => + vi.advanceTimersByTime(MENTION_RESOLVE_TIMEOUT_MS - 1), + ); + expect(h.chip("mira")).toHaveAttribute("aria-busy", "true"); + expect(signal?.aborted).toBe(false); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => vi.advanceTimersByTime(1)); + // The bound told the provider's in-flight work to stop, the chip returned + // to prose, and the waiting send kept the draft with a notice. + expect(pendingAtAbort).toBe(true); + expect(h.chip("mira")).toBeNull(); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(screen.getByRole("alert")).toHaveTextContent( + "@mira could not be confirmed as one person or agent. Send again to send it as text.", + ); + expect(h.input()).toHaveValue("@mira "); + } finally { + vi.useRealTimers(); + } + h.submit(); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@mira ", + [], + [], + ), + ); + expect(h.input()).toHaveValue(""); +}); + +it("removing a pending chip before the bound aborts the provider's signal and leaves no bound timer behind", async () => { + // A provider that ignores abort must not keep the host's 8 s timer alive + // after the chip left: the bound is cleared with the chip's signal. + const h = mountPendingMention(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + const timers = vi.getTimerCount(); + expect(h.hold("honey", new Promise(() => {}))).toBe(false); + const signal = h.resolve.mock.calls[0]?.[0]; + expect(signal?.aborted).toBe(false); + // Typing leaves the editor a short timer of its own; past it, the bound + // is the one timer the chip added. + act(() => vi.advanceTimersByTime(100)); + expect(vi.getTimerCount()).toBe(timers + 1); + act(() => h.input().undo(false)); + expect(h.input()).toHaveValue("@honey"); + expect(h.chip("honey")).toBeNull(); + expect(signal?.aborted).toBe(true); + // The undo leaves the editor's short timer again; past it, nothing: the + // bound went with the chip's signal rather than running on to 8 s. + act(() => vi.advanceTimersByTime(100)); + expect(vi.getTimerCount()).toBe(timers); + } finally { + vi.useRealTimers(); + } +}); + +it("undo after an extended chip settled first removes the words typed after it, then leaves the typed `@mira`", async () => { + const h = mountDirectoryMention(); + await h.holdMira(); + await h.user.keyboard("Example hi"); + await waitFor(() => expect(h.people).toHaveBeenCalled()); + await h.release({ people: [mira], hasMore: false }); + await waitFor(() => expect(h.input()).toHaveValue("@Mira Example hi")); + act(() => h.input().undo(false)); + expect(h.input()).toHaveValue("@Mira Example"); + expect(h.recipients()).toHaveLength(1); + act(() => h.input().undo(false)); + expect(h.input()).toHaveValue("@mira"); + expect(h.chip("mira")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); +}); + +it("returns a pending mention to prose when its recipient is not admitted at settle", async () => { + const h = mountPendingMention(); + const outcome = deferred(); + try { + h.hold("eve", outcome.promise); + // The destination became a Solo chat meanwhile: outsiders are not admitted. + Object.assign(h.session, { + channels: { list: () => soloList, subscribeList: () => () => {} }, + }); + await act(async () => + outcome.resolve({ pubkey: "e".repeat(64), name: "Eve" }), + ); + expect(h.input()).toHaveValue("@eve "); + expect(h.chip("eve")).toBeNull(); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + } finally { + outcome.resolve(null); + } +}); + +it("returns a pending mention to prose when its resolution is not a well-formed recipient", async () => { + // The resolution is a provider's answer: the same shape check a picked + // mention gets, so a name-less object never becomes an `@undefined` chip. + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + try { + h.hold("honey", outcome.promise); + await act(async () => + outcome.resolve({ + pubkey: first.pubkey, + name: undefined, + } as unknown as MentionRecipient), + ); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toBeNull(); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + } finally { + outcome.resolve(null); + } +}); + +/** `@honey ` held, Send pressed while it is pending. */ +async function sendWhilePending( + options: Partial = {}, + prepare?: (h: ReturnType) => void, +) { + const h = mountPendingMention(options); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + h.hold("honey", outcome.promise); + prepare?.(h); + h.submit(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(), + ); + expect(h.messages.send).not.toHaveBeenCalled(); + return { ...h, outcome }; +} + +it("sending while a mention is pending waits for it, then sends the recipient", async () => { + const h = await sendWhilePending(); + await act(async () => h.outcome.resolve(first)); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@Honey ", + [first.pubkey], + [], + ), + ); +}); + +it("sending while a mention is pending keeps the draft when it returns to prose, and the next send sends the text", async () => { + const h = await sendWhilePending(); + await act(async () => h.outcome.resolve(null)); + expect(screen.getByRole("alert")).toHaveTextContent( + "@honey could not be confirmed as one person or agent. Send again to send it as text.", + ); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toBeNull(); + expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled(); + h.submit(); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@honey ", + [], + [], + ), + ); + expect(h.input()).toHaveValue(""); + expect(screen.queryByRole("alert")).toBeNull(); +}); + +// The resolver found a recipient, but the host would not tag it: the alert +// says why, not that the name matched no one. +it.each([ + { + refusal: "32 recipients are already tagged", + resolution: first, + prepare: (h: ReturnType) => + act(() => { + h.input().insertText( + "", + Array.from({ length: 32 }, () => second), + ); + }), + alert: + "@honey could not be tagged: a message can tag at most 32 recipients. Send again to send it as text.", + }, + { + refusal: "the recipient is not admitted here", + resolution: { pubkey: "e".repeat(64), name: "Eve" }, + // The destination became a Solo chat meanwhile: outsiders are not admitted. + prepare: (h: ReturnType) => + Object.assign(h.session, { + channels: { list: () => soloList, subscribeList: () => () => {} }, + }), + alert: "@honey could not be tagged here. Send again to send it as text.", + }, + { + refusal: "its current name would overrun the length limit", + resolution: { pubkey: first.pubkey, name: "H".repeat(16000) }, + prepare: undefined, + alert: + "@honey could not be tagged: the message would be too long. Send again to send it as text.", + }, +])( + "sending while a mention is pending says why when it resolves but $refusal", + async ({ resolution, prepare, alert }) => { + const h = await sendWhilePending({}, prepare); + await act(async () => h.outcome.resolve(resolution)); + expect(screen.getByRole("alert")).toHaveTextContent(alert); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(h.input().value).toMatch(/^@honey /); + expect(h.chip("honey")).toBeNull(); + expect(screen.getByRole("button", { name: "Send message" })).toBeEnabled(); + }, +); + +it("sending while two mentions are pending says why each was not tagged", async () => { + const h = mountPendingMention(); + const unmatched = deferred(); + const long = deferred(); + h.hold("honey", unmatched.promise); + act(() => { + h.input().insertText("@eve"); + }); + h.resolve.mockImplementation(() => long.promise); + act(() => { + h.publish()({ + items: [], + spacePending: { name: "eve", resolve: h.resolve }, + }); + }); + fireEvent.keyDown(h.input(), { key: " " }); + expect(h.chip("eve")).not.toBeNull(); + h.submit(); + await waitFor(() => + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(), + ); + await act(async () => { + unmatched.resolve(null); + long.resolve({ pubkey: first.pubkey, name: "H".repeat(16000) }); + }); + expect(screen.getByRole("alert")).toHaveTextContent( + "@honey could not be confirmed as one person or agent. @eve could not be tagged: the message would be too long. Send again to send them as text.", + ); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(h.input()).toHaveValue("@honey @eve "); +}); + +it("sending while a mention is pending asks for a recipient when it returns to prose and one is required", async () => { + const h = await sendWhilePending({ requireMention: true }); + await act(async () => h.outcome.resolve(null)); + expect(screen.getByRole("alert")).toHaveTextContent( + "@honey could not be confirmed as one person or agent. Tag a person or agent to send.", + ); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(h.input()).toHaveValue("@honey "); + // Untagged: Send stays focusable so its tooltip can say why. + expect(screen.getByRole("button", { name: "Send message" })).toHaveAttribute( + "aria-disabled", + "true", + ); +}); + +it("asks about a settled outside agent before sending, like a chosen one", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey", isAgent: true }]])); + const list = { + status: "ready", + channels: [ + { id: "channel", channelType: "stream", members: ["d".repeat(64)] }, + ], + }; + Object.assign(h.session, { + viewer: "d".repeat(64), + channels: { list: () => list, subscribeList: () => () => {} }, + memberAdditions: { add: vi.fn() }, + }); + const outcome = deferred(); + try { + h.hold("honey", outcome.promise); + await act(async () => outcome.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + h.submit(); + expect(screen.getByRole("dialog")).toHaveTextContent( + "Honey is not in this channel. Invite them to the channel, or send without inviting them.", + ); + fireEvent.click(screen.getByRole("button", { name: "Do nothing" })); + await act(async () => {}); + expect(h.messages.send).toHaveBeenCalledOnce(); + expect(h.messages.send.mock.calls[0]?.slice(0, 3)).toEqual([ + "channel", + "@Honey ", + [], + ]); + expect(h.messages.send.mock.calls[0]?.at(-1)).toEqual([first.pubkey]); + } finally { + outcome.resolve(null); + } +}); + +it("keeps the resolver of a chip removed as its resolution landed, so redo settles it", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const landed = deferred(); + const restarted = deferred(); + try { + h.hold("honey", landed.promise); + // The resolution lands in the same tick as the undo removing its chip, + // before the commit whose effect would have aborted it: nothing to settle. + await act(async () => { + landed.resolve(first); + h.input().undo(false); + }); + expect(h.input()).toHaveValue("@honey"); + expect(h.resolve).toHaveBeenCalledOnce(); + h.resolve.mockImplementation(() => restarted.promise); + act(() => h.input().undo(true)); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toBeInTheDocument(); + expect(h.resolve).toHaveBeenCalledTimes(2); + await act(async () => restarted.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + expect(h.chip("honey")).toBeNull(); + } finally { + landed.resolve(null); + restarted.resolve(null); + } +}); + +it("holds Space as pending when the publication withdrew the displayed exact match as undecided", async () => { + const h = mountPendingMention(); + const outcome = deferred(); + h.resolve.mockImplementation(() => outcome.promise); + try { + h.fill("@honey"); + const publish = h.publish(); + act(() => { + publish({ + items: [{ id: first.pubkey, label: "Honey", edit: { mention: first } }], + spaceId: first.pubkey, + }); + }); + expect(screen.getByRole("option", { name: "Honey" })).toBeVisible(); + act(() => { + // The display is not overruled by another recipient; the decision is + // deferred to the resolver under the full rule. + publish({ + items: [], + spacePending: { name: "honey", resolve: h.resolve }, + }); + expect(fireEvent.keyDown(h.input(), { key: " " })).toBe(false); + }); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toHaveAttribute("aria-busy", "true"); + expect(h.resolve).toHaveBeenCalledOnce(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + await act(async () => outcome.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + } finally { + outcome.resolve(null); + } +}); + +it("waits for a settle held through a composition before sending, then sends and remembers the recipient", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + try { + h.hold("honey", outcome.promise); + act(() => fireEvent.compositionStart(h.input())); + await act(async () => outcome.resolve(first)); + expect(h.chip("honey")).toHaveAttribute("aria-busy", "true"); + // Send clicked under the composition: Mac WebKit leaves focus in the + // editor, so the IME has not committed and the chip is still pending. + h.submit(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Send message" }), + ).toBeDisabled(), + ); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => fireEvent.compositionEnd(h.input())); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@Honey ", + [first.pubkey], + [], + ), + ); + expect([...(mentionHistory(h.session, "channel")?.keys() ?? [])]).toContain( + first.pubkey, + ); + } finally { + outcome.resolve(null); + } +}); + +it("settles a resolution landing after the composition ended under a send, and sends the recipient", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + try { + h.hold("honey", outcome.promise); + act(() => fireEvent.compositionStart(h.input())); + h.submit(); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Send message" }), + ).toBeDisabled(), + ); + // The IME commits while the send has the editor read-only: ProseMirror + // skips its own compositionend there and keeps its `composing` flag, so + // only the React flag says the composition is over. The resolution lands + // after that, and nothing else will come to replay a held settle. + await act(async () => fireEvent.compositionEnd(h.input())); + expect(h.chip("honey")).toHaveAttribute("aria-busy", "true"); + expect(h.messages.send).not.toHaveBeenCalled(); + await act(async () => outcome.resolve(first)); + await waitFor(() => + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@Honey ", + [first.pubkey], + [], + ), + ); + expect(h.chip("honey")).toBeNull(); + } finally { + outcome.resolve(null); + } +}); + +it("keeps the resolver of a chip gone when its held settle replays, and settles nothing", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const held = deferred(); + const restarted = deferred(); + try { + h.hold("honey", held.promise); + act(() => fireEvent.compositionStart(h.input())); + await act(async () => held.resolve(first)); + expect(h.chip("honey")).toHaveAttribute("aria-busy", "true"); + await act(async () => { + h.input().querySelector("[data-source]")?.remove(); + }); + expect(h.chip("honey")).toBeNull(); + h.resolve.mockImplementation(() => restarted.promise); + await act(async () => fireEvent.compositionEnd(h.input())); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + expect(h.resolve).toHaveBeenCalledOnce(); + // Undo restores the chip under the same id, and its kept resolver starts again. + act(() => h.input().undo(false)); + expect(h.chip("honey")).toBeInTheDocument(); + expect(h.resolve).toHaveBeenCalledTimes(2); + await act(async () => restarted.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + expect(h.chip("honey")).toBeNull(); + } finally { + held.resolve(null); + restarted.resolve(null); + } +}); + +it("returns a pending chip to its typed query when a name character follows it, so its resolution tags no one", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const outcome = deferred(); + try { + h.hold("honey", outcome.promise); + const signal = h.resolve.mock.calls[0]?.[0]; + const queries = h.queries(); + await h.user.keyboard("{Backspace}suckle"); + expect(h.input()).toHaveValue("@honeysuckle"); + expect(h.chip("honey")).toBeNull(); + expect(h.input().querySelector("[data-source]")).toBeNull(); + expect(signal?.aborted).toBe(true); + // The chooser reopens over the whole word. + expect(h.queries()).toBeGreaterThan(queries); + await act(async () => outcome.resolve(first)); + expect(h.input()).toHaveValue("@honeysuckle"); + expect( + screen.queryByRole("region", { name: "Explicit mentions" }), + ).toBeNull(); + h.submit(); + expect(h.messages.send).toHaveBeenCalledExactlyOnceWith( + "channel", + "@honeysuckle", + [], + [], + ); + } finally { + outcome.resolve(null); + } +}); +it("removing a pending chip abandons its resolution, and redo starts it again", async () => { + const h = mountPendingMention(); + h.setProfiles(new Map([[first.pubkey, { name: "Honey" }]])); + const abandoned = deferred(); + const restarted = deferred(); + try { + h.hold("honey", abandoned.promise); + const signal = h.resolve.mock.calls[0]?.[0]; + act(() => h.input().undo(false)); + expect(h.input()).toHaveValue("@honey"); + expect(signal?.aborted).toBe(true); + h.resolve.mockImplementation(() => restarted.promise); + act(() => h.input().undo(true)); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toBeInTheDocument(); + expect(h.resolve).toHaveBeenCalledTimes(2); + expect(h.resolve.mock.calls[1]?.[0]?.aborted).toBe(false); + // The abandoned resolution settling late changes nothing. + await act(async () => abandoned.resolve(second)); + expect(h.input()).toHaveValue("@honey "); + expect(h.chip("honey")).toBeInTheDocument(); + await act(async () => restarted.resolve(first)); + expect(h.input()).toHaveValue("@Honey "); + expect(h.chip("honey")).toBeNull(); + } finally { + abandoned.resolve(null); + restarted.resolve(null); + } +}); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 6f86874be5..848e767041 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -99,11 +99,13 @@ import { isEmojiOnly, usesLargeEmojiPresentation } from "./emoji-size"; import { legacyFollowupDraft, MAX_DRAFT_GIFS, + mentionBoundary, mentionDraft, stageableGif, type MentionDraft, type MentionRecipient, } from "./mention-draft"; +import { normalizeName } from "../search/person-match"; import { ComposerAccessories } from "../conversation/ComposerAccessories"; import { ComposerTools } from "../conversation/ComposerTools"; import { ComposerLinkDialog } from "./ComposerLinkDialog"; @@ -114,11 +116,14 @@ import type { MentionPicks } from "../relay/messages"; import type { ConversationExtensions, CompletionEdit, + CompletionPending, CompletionQuery, + CompletionResolution, ComposerObservation, ComposerResource, } from "../conversation/contracts"; import { ComposerCompletions } from "../conversation/ComposerCompletions"; +import { MENTION_RESOLVE_TIMEOUT_MS } from "../conversation/completion"; import { useCompletionEditor } from "../conversation/useCompletionEditor"; import { formatMediaTime, mediaTimeReply } from "./media-timecode"; import { RichComposerInput } from "./RichComposerInput"; @@ -195,6 +200,28 @@ function errorForRecovery(reason: unknown) { ? String(reason) : undefined; } +/** What `MentionRecipient` promises, checked where one arrives from a + * contribution: a 64-hex pubkey and a non-empty name. */ +function wellFormedRecipient( + recipient: unknown, +): recipient is MentionRecipient { + if (typeof recipient !== "object" || recipient === null) return false; + const { pubkey, name } = recipient as { pubkey?: unknown; name?: unknown }; + return ( + typeof pubkey === "string" && + /^[0-9a-f]{64}$/.test(pubkey) && + typeof name === "string" && + !!name.trim() + ); +} +/** Why a pending mention returned to prose, as the send it held tells it. */ +const pendingRefusals = { + unresolved: "could not be confirmed as one person or agent", + unavailable: "could not be tagged here", + limit: "could not be tagged: a message can tag at most 32 recipients", + length: "could not be tagged: the message would be too long", +} as const; +type PendingRefusal = { name: string; reason: keyof typeof pendingRefusals }; function subscribeRecovery( session: ComposerSession, key: string, @@ -340,9 +367,35 @@ function PersonalComposer(props: MessageComposerProps) { session.viewer, ], ); + // The agents still loading may include the name a Space was typed for. That + // pending mention resolves against the first roster committed once they + // settle. Each loading period has its own promise: a Retry after a failed + // read must not hand out the one the failure settled with an empty roster. + const loading = choices.pending && choices.status !== "ready"; + const settling = useRef<{ + promise: Promise; + resolve: (roster: readonly MentionRecipient[]) => void; + }>(undefined); + if (loading && !settling.current) { + let resolve!: (roster: readonly MentionRecipient[]) => void; + const promise = new Promise((done) => { + resolve = done; + }); + settling.current = { promise, resolve }; + } + const pending = loading ? settling.current?.promise : undefined; + useLayoutEffect(() => { + if (loading || !settling.current) return; + settling.current.resolve(roster); + settling.current = undefined; + }, [loading, roster]); + const value = useMemo( + () => ({ recipients: roster, ...(pending ? { pending } : {}) }), + [roster, pending], + ); return ( - + {choices.error && (

@@ -457,7 +510,10 @@ function Composer({ live.current = false; }; }, []); - const mentionRoster = useContext(DraftMentionRoster); + const mentionRoster = useContext(DraftMentionRoster)?.recipients; + // A pending mention settles after later renders; admit it against the roster then. + const currentRoster = useRef(mentionRoster); + currentRoster.current = mentionRoster; const solo = !!soloConversation || ownSoloConversation; // Only a saved Solo chat subscribes; other composers never read this. const withPeople = useSoloHasPeople(session, channelId, solo && !submission); @@ -614,7 +670,7 @@ function Composer({ session, channelId, agentChoices, - mentionRoster, + currentRoster.current, new Set((agents?.joinable() ?? []).map((agent) => agent.pubkey)), ); const sessionPlace = useMemo( @@ -1137,6 +1193,15 @@ function Composer({ } }; }, [editScope]); + // DOM props are committed before child layout effects; closures can still + // carry the preceding render's enabled state during that interval. + const editable = () => + permitted.current && + !editingDisabled && + !!outbox?.supports(9) && + !!input.current?.isConnected && + !input.current.disabled && + !input.current.readOnly; function insert( text: string, recipient?: MentionRecipient, @@ -1144,7 +1209,8 @@ function Composer({ terminator?: ":", focus = true, ) { - if (!writable() || !input.current || typeof text !== "string") return false; + const field = input.current; + if (!editable() || !field || typeof text !== "string") return false; // Edits replace prose; they do not change the original notification recipients. if (editing.target && recipient) { text = editMentionText(recipient); @@ -1161,7 +1227,7 @@ function Composer({ return false; } completion.invalidate(); - if (!input.current.insertText(text, recipient, range, terminator, focus)) { + if (!field.insertText(text, recipient, range, terminator, focus)) { setError("Message is too long to insert text"); return false; } @@ -1169,18 +1235,6 @@ function Composer({ setError(undefined); return true; } - function writable() { - return ( - permitted.current && - !editingDisabled && - !!outbox?.supports(9) && - !!input.current?.isConnected && - // DOM props are committed before child layout effects; closures can still - // carry the preceding render's enabled state during that interval. - !input.current.disabled && - !input.current.readOnly - ); - } /** A tool's lone GIF joins the attachment row, sent after the caption like * the row's other media, rather than sitting in prose at the caret. An edit * has no attachment row, so its GIF stays in the text. */ @@ -1192,7 +1246,7 @@ function Composer({ !draftGif(text, session.media) ) return; - if (!writable()) return false; + if (!editable()) return false; const current = valueRef.current.gifs ?? []; if (!current.includes(text)) { if (current.length >= MAX_DRAFT_GIFS) { @@ -1210,15 +1264,198 @@ function Composer({ input.current?.focus(); return true; } - function insertMention(recipient: MentionRecipient) { - if ( - !recipient || - typeof recipient.pubkey !== "string" || - !/^[0-9a-f]{64}$/.test(recipient.pubkey) || - typeof recipient.name !== "string" || - !recipient.name.trim() + // A Space typed before the chooser could decide holds its name as a chip + // with no recipient; the provider's resolver settles it off-screen. The + // chip's id keys its resolver here, and the draft says which chips exist. + const pendingMentions = useRef( + new Map< + string, + { + resolve: CompletionPending["resolve"]; + controller?: AbortController | undefined; + /** Settles with the typed name and why when the chip returned to prose. */ + settled?: Promise | undefined; + } + >(), + ); + function settlePendingMention( + id: string, + recipient: MentionRecipient | null, + signal: AbortSignal, + ) { + const entry = pendingMentions.current.get(id); + if (!entry || signal.aborted) return; + // Exactly what Tab would have inserted: well formed (the resolution is a + // provider's answer), admitted now, within the cap. + const reason: PendingRefusal["reason"] | undefined = !wellFormedRecipient( + recipient, ) - return false; + ? "unresolved" + : !admits()(recipient.pubkey) + ? "unavailable" + : valueRef.current.recipients.length >= 32 + ? "limit" + : undefined; + const accepted = + recipient && !reason + ? { pubkey: recipient.pubkey, name: recipient.name } + : null; + const name = valueRef.current.pending?.find((chip) => chip.id === id)?.name; + // Held through an IME composition, the outcome is a promise: the chip is + // still pending until the replay, so the entry stays until it lands. + return Promise.resolve( + input.current?.settlePendingMention(id, accepted) ?? false, + ).then((applied) => settled(id, accepted, applied, name ?? "", reason)); + } + function settled( + id: string, + accepted: MentionRecipient | null, + applied: boolean, + name: string, + reason: PendingRefusal["reason"] | undefined, + ): PendingRefusal | undefined { + const entry = pendingMentions.current.get(id); + if (!entry) return; + if (!applied) { + // The chip left the draft in the commit before the effect below could + // abort this resolution. Keep its resolver: redo restores the chip under + // the same id, and the effect starts it again. + entry.controller = undefined; + entry.settled = undefined; + return; + } + pendingMentions.current.delete(id); + if ( + accepted && + valueRef.current.recipients.some((p) => p.pubkey === accepted.pubkey) + ) { + rememberMention(session, channelId, accepted.pubkey); + return; + } + // Accepted but written as text: its current name overran the length limit. + return { name, reason: reason ?? "length" }; + } + function resolvePendingMention(id: string) { + const entry = pendingMentions.current.get(id); + if (!entry) return; + if (!entry.controller) { + const controller = new AbortController(); + entry.controller = controller; + entry.settled = resolveChip( + id, + (s) => bounded(entry.resolve, s), + controller.signal, + ); + } + return entry.settled; + } + /** One resolution per chip, through the longer names it continues into: + * a chip whose name only longer known names kept from selecting extends + * over the words typed after it when they complete one, and that name is + * resolved under the same signal. Anything else returns it to prose, and + * the promise settles with the typed name it returned and why. */ + async function resolveChip( + id: string, + resolve: CompletionPending["resolve"], + signal: AbortSignal, + ): Promise { + const outcome = await resolve(signal).catch(() => null); + if (signal.aborted) return; + if (!outcome || !("continued" in outcome)) + return settlePendingMention(id, outcome, signal); + const next = typedContinuation(id, outcome.continued); + const extended = + next && + (await Promise.resolve( + input.current?.extendPendingMention(id, next.remainder) ?? false, + )); + if (signal.aborted) return; + if (next && extended) + return resolveChip(id, (s) => bounded(next.resolve, s), signal); + return settlePendingMention(id, null, signal); + } + /** `resolve` held to `MENTION_RESOLVE_TIMEOUT_MS`. The contract does not + * ask a provider to time itself, so the host bounds every link: the first + * resolution and each longer name a chip extends into. The bundled resolver + * races its first resolution against the same constant, redundant under + * this but harmless, and answers a chained resolve at once over the page it + * already read. Expiry is null, prose, as the resolver's own bound is. + * The provider gets its own signal, which follows the chip's and is also + * aborted at expiry, so work that outlives the bound is told to stop; and + * the chip's abort clears the timer, so a provider that ignores it leaves + * nothing running after Backspace or unmount. */ + function bounded( + resolve: CompletionPending["resolve"], + signal: AbortSignal, + ): Promise { + return new Promise((settle) => { + const own = new AbortController(); + const timer = setTimeout(() => { + settle(null); + end(); + }, MENTION_RESOLVE_TIMEOUT_MS); + const end = () => { + clearTimeout(timer); + signal.removeEventListener("abort", end); + own.abort(); + }; + signal.addEventListener("abort", end, { once: true }); + resolve(own.signal) + .then(settle, () => settle(null)) + .finally(end); + }); + } + /** The longest of `continued` that the chip's name plus the words typed + * after it, up to a name boundary, spell: the name Space would have needed + * typed in full. None when the words complete no name or stop mid-word. + * The remainder ends on the name's last character: `normalizeName` trims, + * so `Example ` spells the same name as `Example`, and extending over the + * separator would join the chip to whatever is typed after it. */ + function typedContinuation( + id: string, + continued: readonly CompletionPending[], + ) { + const chip = valueRef.current.pending?.find((item) => item.id === id); + if (!chip) return undefined; + const { text } = valueRef.current; + const names = new Map(continued.map((c) => [normalizeName(c.name), c])); + const longest = Math.max(...[...names.keys()].map((n) => n.length)); + let found: + | { remainder: string; resolve: CompletionPending["resolve"] } + | undefined; + for (let end = chip.end + 1; end <= text.length; end++) { + const remainder = text.slice(chip.end, end); + const name = normalizeName(chip.name + remainder); + if (name.length > longest) break; + if (/\s$/u.test(remainder) || !mentionBoundary(text, end)) continue; + const next = names.get(name); + if (next) found = { remainder, resolve: next.resolve }; + } + return found; + } + // biome-ignore lint/correctness/useExhaustiveDependencies: Reconciles resolvers against the chips in the draft; the helpers read current refs. + useEffect(() => { + const alive = new Set(value.pending?.map((chip) => chip.id)); + for (const [id, entry] of pendingMentions.current) { + if (alive.has(id)) resolvePendingMention(id); + else if (entry.controller) { + // Backspace, undo or Clear removed the chip. Redo brings it back under + // the same id, and this effect starts its resolution again. + entry.controller.abort(); + entry.controller = undefined; + entry.settled = undefined; + } + } + }, [value.pending]); + useEffect( + () => () => { + for (const entry of pendingMentions.current.values()) + entry.controller?.abort(); + }, + [], + ); + function insertMention(recipient: MentionRecipient) { + if (!wellFormedRecipient(recipient)) return false; if (!solo || editing.target) return insert(`@${recipient.name} `, recipient); return keepProseCaret((at) => @@ -1389,6 +1626,18 @@ function Composer({ ) return false; } + if ("pending" in edit && edit.pending) { + // Edits replace prose and add no recipients, so a chip that may become + // one has no place there: the typed Space stays literal. + const field = input.current; + if (editing.target || !editable() || !field) return false; + completion.invalidate(); + const id = field.insertPendingMention(edit.pending.name, query); + if (!id) return false; + pendingMentions.current.set(id, { resolve: edit.pending.resolve }); + setError(undefined); + return true; + } if ("mentions" in edit && edit.mentions) return insertMentions(edit.mentions, query); if ("mention" in edit && edit.mention) @@ -1544,8 +1793,12 @@ function Composer({ const recipientHint = submission ? "Tag an agent to send" : "Tag a person or agent to send"; + // A pending mention may yet become the recipient; sending waits for it. const untagged = - needsRecipient && !editing.target && !value.recipients.length; + needsRecipient && + !editing.target && + !value.recipients.length && + !value.pending?.length; async function prepareRecipients(explicit: readonly string[]) { const channel = await session.channelWrites.refreshMembership(channelId); if (!currentAdmission()) @@ -1639,6 +1892,70 @@ function Composer({ !outbox ) return; + if (valueRef.current.pending?.length) { + // Enter straight after Space is the case a pending mention exists for. + // Wait for its recipient rather than send prose; the composer shows + // its sending state meanwhile. The wait is bounded: `resolveChip` + // holds each link of a resolution, the first and each longer name the + // chip extends into, to `MENTION_RESOLVE_TIMEOUT_MS` (the contract + // lets a provider's resolve do I/O without timing itself); plus an IME + // composition that was already open when Send was clicked, since a + // settle landing under it is held until the composition ends. + const wait = new AbortController(); + sendAttempt.current = wait; + setSending(true); + setError(undefined); + let outcomes: + | PromiseSettledResult[] + | undefined; + try { + outcomes = + (await Promise.race([ + Promise.allSettled( + valueRef.current.pending.map((chip) => + resolvePendingMention(chip.id), + ), + ), + new Promise((resolve) => { + wait.signal.addEventListener("abort", () => resolve(), { + once: true, + }); + }), + ])) || undefined; + } finally { + if (sendAttempt.current === wait) { + sendAttempt.current = null; + if (live.current) setSending(false); + } + } + if (wait.signal.aborted || !live.current || !permitted.current) return; + // A chip this send waited on that returned to prose would go out as a + // name that notifies no one, after the user saw it held. Keep the + // draft and say why, so the user can fix it (a recipient over the cap) + // or send again to send it as text, since nothing is pending by then. + const prose = (outcomes ?? []).flatMap((outcome) => + outcome.status === "fulfilled" && outcome.value ? [outcome.value] : [], + ); + if (prose.length) { + const why = Object.entries(pendingRefusals).flatMap( + ([reason, says]) => { + const names = prose + .filter((chip) => chip.reason === reason) + .map((chip) => `@${chip.name}`); + return names.length ? [`${names.join(", ")} ${says}.`] : []; + }, + ); + setError( + [ + ...why, + needsRecipient && !valueRef.current.recipients.length + ? `${recipientHint}.` + : `Send again to send ${prose.length > 1 ? "them" : "it"} as text.`, + ].join(" "), + ); + return; + } + } if (needsRecipient && !valueRef.current.recipients.length) { setError(recipientHint); return; diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 2e11a08949..628ccc7e87 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -48,7 +48,7 @@ export function RichComposerInput({ (channel) => channel?.members, ); const archives = useMentionArchives(session); - const roster = useContext(DraftMentionRoster); + const roster = useContext(DraftMentionRoster)?.recipients; const resolveName = useIdentityNames(session.names); // Recipients, not keystrokes, change chip names: typing prose must not // copy every profile or scan every member of a large channel. diff --git a/src/features/messages/composer-document.ts b/src/features/messages/composer-document.ts index 96cf80d9cf..211a8a0def 100644 --- a/src/features/messages/composer-document.ts +++ b/src/features/messages/composer-document.ts @@ -7,7 +7,11 @@ import { import { bulletList, orderedList, listItem } from "prosemirror-schema-list"; import { composerLinkUrl } from "./composer-link"; import { scanMarkdown } from "../relay/message-content"; -import type { MentionDraft, DraftRecipient } from "./mention-draft"; +import type { + MentionDraft, + DraftRecipient, + PendingMention, +} from "./mention-draft"; import type { ComposerResource } from "../conversation/contracts"; /** The document owns editable marks and exact source tokens. Unimplemented @@ -95,6 +99,9 @@ export const composerSchema = new Schema< recipient: { default: null }, /** Host-owned `{ uri, label }`; valid only while `source` is its link. */ resource: { default: null }, + /** `{ id, name }` while the host resolves a mention Space committed + * before the chooser could decide. Never persists past the editor. */ + pending: { default: null }, editAsText: { default: false }, }, leafText: (node) => node.attrs.source, @@ -227,6 +234,9 @@ export function readComposerDocument( ); if (node.isText) return node.mark(marks); if (node.type.name === "token") { + // The resolver died with the previous editor; the typed text stands. + if (node.attrs.pending) + return composerSchema.text(node.attrs.source, marks); const resource = composerResource(node.attrs.resource); return node.type.create( { @@ -371,6 +381,7 @@ export function projectComposerDocument(doc: EditorNode) { let text = ""; const positions = new Array(doc.content.size + 1).fill(0); const recipients: DraftRecipient[] = []; + const pending: PendingMention[] = []; const carets: number[] = []; const blocks: { from: number; to: number; start: number; code: boolean }[] = []; @@ -435,6 +446,8 @@ export function projectComposerDocument(doc: EditorNode) { }); if (node.attrs.recipient) recipients.push({ ...node.attrs.recipient, start, end: text.length }); + if (node.attrs.pending) + pending.push({ ...node.attrs.pending, start, end: text.length }); } }); for (let i = 1; i < positions.length; i++) @@ -444,6 +457,7 @@ export function projectComposerDocument(doc: EditorNode) { draft: { text, recipients, + ...(pending.length ? { pending } : {}), document: { version: 1, content: doc.toJSON() }, } satisfies MentionDraft, positions, diff --git a/src/features/messages/composer-dom.ts b/src/features/messages/composer-dom.ts index 28bcb9d358..efa37b9d2e 100644 --- a/src/features/messages/composer-dom.ts +++ b/src/features/messages/composer-dom.ts @@ -88,6 +88,29 @@ export type ComposerInputElement = HTMLDivElement & { focus?: boolean, ): boolean; replaceRecipients(recipients: readonly MentionRecipient[]): boolean; + /** Replace the typed `@name` with a chip the host is still resolving; the + * returned id names it until `settlePendingMention`. One undo step. */ + insertPendingMention( + name: string, + range: { start: number; end: number }, + ): string | false; + /** Turn a pending chip into its recipient, or back into its typed text. + * Outside undo history and allowed while sending; false once the chip is + * gone. Under an IME composition the settle is held until it ends, and the + * promise is the replay's outcome. */ + settlePendingMention( + id: string, + recipient: MentionRecipient | null, + ): boolean | Promise; + /** Extend a pending chip over `remainder`, the text right after it, into one + * chip named by both: the host matched the words typed after the chip to a + * longer known name. Outside undo history and allowed while sending, held + * under an IME composition like a settle; false once the chip is gone or the + * text after it no longer begins with `remainder`. */ + extendPendingMention( + id: string, + remainder: string, + ): boolean | Promise; /** Host-owned inline link atom at the caret; a string is the rejection reason. */ insertResource(resource: ComposerResource): true | string; toggleFormat(format: ComposerFormat): void; diff --git a/src/features/messages/draft-mention-roster.ts b/src/features/messages/draft-mention-roster.ts index ead29b20b2..e8d633bea8 100644 --- a/src/features/messages/draft-mention-roster.ts +++ b/src/features/messages/draft-mention-roster.ts @@ -2,6 +2,14 @@ import { createContext } from "react"; import type { MentionRecipient } from "./mention-draft"; /** Composer-local candidates before a channel exists; delivery still validates membership. */ -export const DraftMentionRoster = createContext< - readonly MentionRecipient[] | undefined ->(undefined); +export type MentionRoster = { + recipients: readonly MentionRecipient[]; + /** Set while the owner's sources are still loading, so a name may be + * missing: the recipients once they settle. A pending mention outlives the + * chooser that saw the partial roster and resolves against these. */ + pending?: Promise | undefined; +}; + +export const DraftMentionRoster = createContext( + undefined, +); diff --git a/src/features/messages/mention-draft.ts b/src/features/messages/mention-draft.ts index f64c9a0a62..60d8177db1 100644 --- a/src/features/messages/mention-draft.ts +++ b/src/features/messages/mention-draft.ts @@ -9,6 +9,14 @@ export type DraftRecipient = MentionRecipient & Readonly<{ start: number; end: number }>; // Serializable draft metadata must not expose editor implementation types to plugins. export type ComposerSnapshot = { version: 1; content: unknown }; +/** A name Space committed before the chooser could decide. The editor resolves + * it off-screen into a recipient or back into prose; it is never a recipient. */ +export type PendingMention = Readonly<{ + id: string; + name: string; + start: number; + end: number; +}>; export type MentionDraft = { text: string; /** Chat links staged above the editor; serialized with the caption. */ @@ -16,6 +24,8 @@ export type MentionDraft = { /** GIF image Markdown staged in the attachment row; sent after the caption. */ gifs?: readonly string[]; recipients: readonly DraftRecipient[]; + /** Projection of the live document only; `mentionDraft()` never restores one. */ + pending?: readonly PendingMention[]; document?: ComposerSnapshot; }; // The timeline binds a signed name up to the next non-name character, so the diff --git a/src/features/relay/profile-details.test.ts b/src/features/relay/profile-details.test.ts index a542f0b8e5..84aeb66579 100644 --- a/src/features/relay/profile-details.test.ts +++ b/src/features/relay/profile-details.test.ts @@ -357,3 +357,61 @@ it("promotes a queued background profile read when foreground demand joins it", reader.dispose(); } }); + +it("records identities a completed read found no profile for, until one arrives", async () => { + const other = keypair(); + const third = keypair(); + const read = vi.fn(async () => [profile(user, { name: "Mic" })]); + const directory = createProfileDirectory({ read }); + const listener = vi.fn(); + directory.queries.subscribe(listener); + try { + await directory.ensure([user.pubkey, other.pubkey]); + const absent = directory.queries.unnamed?.(); + expect(absent).toEqual(new Set([other.pubkey])); + // Once for the profile, once for the absence. + expect(listener).toHaveBeenCalledTimes(2); + // A failed read is not evidence. + read.mockRejectedValueOnce(new Error("offline")); + await expect(directory.ensure([third.pubkey])).rejects.toThrow("offline"); + expect(directory.queries.unnamed?.()).toBe(absent); + // A profile that arrives later outranks the absence. + directory.accept([profile(other, { name: "Nu" })]); + expect(directory.queries.snapshot().get(other.pubkey)?.name).toBe("Nu"); + expect(directory.queries.unnamed?.()).toEqual(new Set()); + // Clearing forgets the evidence and tells subscribers so. + await directory.ensure([third.pubkey]); + expect(directory.queries.unnamed?.()).toEqual(new Set([third.pubkey])); + listener.mockClear(); + directory.clear(); + expect(directory.queries.unnamed?.()).toEqual(new Set()); + expect(listener).toHaveBeenCalled(); + } finally { + directory.dispose(); + } +}); + +it("records no absence from a read that filled its page", async () => { + const other = keypair(); + // A relay that keeps every kind 0 can fill the page from one author. The + // directory does not verify events, so older versions are cheap copies. + const head = profile(user, { name: "Mic" }, 1_700_000_500); + const versions = Array.from({ length: 500 }, (_, i) => ({ + ...head, + id: i.toString(16).padStart(64, "0"), + created_at: head.created_at - i, + })); + const read = vi.fn(async () => versions); + const directory = createProfileDirectory({ read }); + try { + await directory.ensure([user.pubkey, other.pubkey]); + expect(directory.queries.snapshot().get(user.pubkey)?.name).toBe("Mic"); + expect(directory.queries.unnamed?.()).toEqual(new Set()); + // One short of the limit is complete. + read.mockResolvedValueOnce(versions.slice(1)); + await directory.ensure([other.pubkey]); + expect(directory.queries.unnamed?.()).toEqual(new Set([other.pubkey])); + } finally { + directory.dispose(); + } +}); diff --git a/src/features/relay/profile-directory.ts b/src/features/relay/profile-directory.ts index c06ad26abc..3aeb6f3fb0 100644 --- a/src/features/relay/profile-directory.ts +++ b/src/features/relay/profile-directory.ts @@ -17,10 +17,16 @@ export interface ProfileQueries { ensure(ids: readonly string[], priority?: Priority): Promise; /** The winning signed kind 0 retained for one identity, for provenance checks. */ event?(pubkey: string): RelayEvent | undefined; + /** Identities a completed read found no profile for, until one arrives. */ + unnamed?(): ReadonlySet; /** Cryptographically verified owner of the retained signed profile head. */ owner?(pubkey: string): string | undefined; } +/** Authors per profile read, the reader's largest page. A page that fills it + * may have left out authors who do have a profile. */ +const PROFILE_PAGE = 500; + /** One bounded source of signed profile events. Display values are derived from it. */ export function createProfileDirectory( reader: RelayReader, @@ -40,6 +46,8 @@ export function createProfileDirectory( >(); const listeners = new Set<() => void>(); let snapshot: ReadonlyMap = new Map(); + // Replaced, never mutated, so stores can compare it like `snapshot`. + let unnamed: ReadonlySet = new Set(); let controller = new AbortController(); let closed = false; function accept(incoming: readonly RelayEvent[]) { @@ -99,9 +107,18 @@ export function createProfileDirectory( if ( next.size !== snapshot.size || [...next].some(([id, value]) => snapshot.get(id) !== value) - ) + ) { snapshot = next; - else if (!headChanged) return; + if ([...unnamed].some((id) => next.has(id))) + unnamed = new Set([...unnamed].filter((id) => !next.has(id))); + } else if (!headChanged) return; + for (const listener of listeners) notify(listener); + } + /** A completed read that returned no kind 0 is evidence too: the identity is known unnamed. */ + function missed(ids: readonly string[]) { + const fresh = ids.filter((id) => !snapshot.has(id) && !unnamed.has(id)); + if (!fresh.length) return; + unnamed = new Set([...unnamed, ...fresh].slice(-1024)); for (const listener of listeners) notify(listener); } async function ensure( @@ -124,18 +141,18 @@ export function createProfileDirectory( batch.priority = priority; if (batch.reading) void reader - .read([{ kinds: [0], authors: batch.authors, limit: 500 }], { - signal, - priority, - }) + .read( + [{ kinds: [0], authors: batch.authors, limit: PROFILE_PAGE }], + { signal, priority }, + ) .catch(() => {}); } waiting.add(batch.work); } return !batch && (refresh || !snapshot.has(id)); }); - for (let offset = 0; offset < missing.length; offset += 500) { - const authors = missing.slice(offset, offset + 500); + for (let offset = 0; offset < missing.length; offset += PROFILE_PAGE) { + const authors = missing.slice(offset, offset + PROFILE_PAGE); let answered = false; const batch = { authors, @@ -148,7 +165,7 @@ export function createProfileDirectory( batch.reading = true; try { const result = await reader.read( - [{ kinds: [0], authors, limit: 500 }], + [{ kinds: [0], authors, limit: PROFILE_PAGE }], { signal, priority: batch.priority }, ); answered = true; @@ -158,8 +175,10 @@ export function createProfileDirectory( } }, signal) .then((result) => { - if (!signal.aborted) - accept(result.filter((event) => authors.includes(event.pubkey))); + if (signal.aborted) return; + accept(result.filter((event) => authors.includes(event.pubkey))); + // A page at the limit may have left out authors who do have one. + if (result.length < PROFILE_PAGE) missed(authors); }) .catch((error: unknown) => { // The old directory contract silently discards a retired success. @@ -179,7 +198,9 @@ export function createProfileDirectory( controller = new AbortController(); pending.clear(); events.clear(); - publish(); + const forgot = unnamed.size > 0; + unnamed = new Set(); + publish(forgot); } const queries: ProfileQueries = Object.freeze({ snapshot: () => snapshot, @@ -191,6 +212,7 @@ export function createProfileDirectory( }, ensure, event: (pubkey: string) => events.peek(pubkey), + unnamed: () => unnamed, owner: (pubkey: string) => { const event = events.peek(pubkey); return event ? owners.get(event) : undefined; diff --git a/src/features/solo/SoloDraft.test.tsx b/src/features/solo/SoloDraft.test.tsx index 51705a14b8..2272eae684 100644 --- a/src/features/solo/SoloDraft.test.tsx +++ b/src/features/solo/SoloDraft.test.tsx @@ -11,6 +11,7 @@ import { render, screen, waitFor, + within, } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import type { RelaySession } from "../relay/session"; @@ -22,11 +23,32 @@ import { createOutbox } from "../relay/outbox"; import { keypair, signed } from "../relay/testing"; import { SOLO_LABEL } from "./labels"; import { attachmentDraft } from "../messages/attachment-draft"; +import { createAgentChoices } from "../agents/choices"; +import { createAgentLibrary } from "../agents/library"; +import { MentionCompletion } from "../../bundled/mentions/MentionCompletion"; +import { mentionQuery } from "../../bundled/mentions/mention-query"; +import type { ComposerCompletion } from "../conversation/contracts"; +import type { Contribution } from "../../plugins/contributions"; composerDOMFixture(); afterEach(cleanup); -afterEach(() => vi.unstubAllGlobals()); -beforeEach(() => localStorage.clear()); +afterEach(() => { + vi.unstubAllGlobals(); + delete (HTMLElement.prototype as Partial).scrollIntoView; +}); +beforeEach(() => { + localStorage.clear(); + // jsdom lacks what the completion popup uses to position and reveal rows. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + HTMLElement.prototype.scrollIntoView = vi.fn(); +}); const existing = { id: "11111111-1111-4111-8111-111111111111", name: "Engineering", @@ -1553,3 +1575,122 @@ it("keeps an edited linked draft instead of reinserting its removed source", asy "@Member agent Changed request", ); }); +const aaron = { pubkey: "e".repeat(64), name: "Aaron" }; +/** A new Solo chat over the real mentions completion, with the agent + * inventory answered by `read`; returns the composer and the `p` tags the + * eventual send carried. */ +function soloDraftWithInventory( + read: () => Promise<{ definitions: []; identities: [typeof aaron] }>, +) { + const test = setup(); + const library = createAgentLibrary(read); + const session = { + ...test.session, + agentChoices: createAgentChoices({ + scope: "test", + library: library.queries, + signal: new AbortController().signal, + }), + } as unknown as RelaySession; + const completions: readonly Contribution[] = [ + { + id: "typeahead", + key: "buzz.mentions/typeahead", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: MentionCompletion, + }, + ]; + const empty: [] = []; + const none = { snapshot: () => empty, subscribe: () => () => {} }; + const onStarted = vi.fn(); + render( + completions, subscribe: () => () => {} }, + }} + />, + ); + const input = screen.getByRole("textbox"); + return { + input, + chip: () => + within(input).queryByRole("img", { name: "Resolving mention Aaron" }), + async send() { + await userEvent.click( + screen.getByRole("button", { name: "Send message" }), + ); + await waitFor(() => expect(onStarted).toHaveBeenCalledOnce()); + const id = test.channelWrites.create.mock.calls[0]?.[0]; + expect(test.channelWrites.start).toHaveBeenCalledWith( + id, + expect.any(String), + [aaron.pubkey], + undefined, + false, + undefined, + ); + expect(test.messages.send).toHaveBeenCalledWith(id, "@Aaron hi", [ + aaron.pubkey, + ]); + }, + dispose: library.dispose, + }; +} +it("tags an agent whose name was typed with Space before the agent inventory loaded", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const draft = soloDraftWithInventory(async () => { + await gate; + return { definitions: [], identities: [aaron] }; + }); + try { + // Fast typing: the inventory has not answered when Space arrives. + await userEvent.type(draft.input, "@Aaron hi"); + expect(draft.input).toHaveValue("@Aaron hi"); + expect(draft.chip()).toHaveAttribute("aria-busy", "true"); + await act(async () => release()); + await waitFor(() => expect(draft.chip()).toBeNull()); + await draft.send(); + } finally { + release(); + draft.dispose(); + } +}); +it("tags an agent whose name was typed with Space while a retry after a failed inventory read loads", async () => { + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + let reads = 0; + const draft = soloDraftWithInventory(async () => { + if (reads++ === 0) throw new Error("offline"); + await gate; + return { definitions: [], identities: [aaron] }; + }); + try { + await userEvent.click( + await screen.findByRole("button", { name: "Retry agents" }), + ); + // The failed read settled the roster empty; the retry's Space must wait + // for the retry, not consume that settled roster. + await userEvent.type(draft.input, "@Aaron hi"); + expect(draft.input).toHaveValue("@Aaron hi"); + expect(draft.chip()).toHaveAttribute("aria-busy", "true"); + await act(async () => release()); + await waitFor(() => expect(draft.chip()).toBeNull()); + await draft.send(); + } finally { + release(); + draft.dispose(); + } +}); diff --git a/src/shared/design-system/styles/chips.css b/src/shared/design-system/styles/chips.css index 420334ff3a..a34a7bb368 100644 --- a/src/shared/design-system/styles/chips.css +++ b/src/shared/design-system/styles/chips.css @@ -56,6 +56,57 @@ color: var(--text-metadata); } + /** + * Resolving reference: a mention committed before the client could decide + * on its recipient. The sigil keeps its exact advance width and baseline but + * paints transparent, and the spinner sits in its box, so settling into a + * recipient (or back into text) moves nothing. The spinner names its own + * colour because it would otherwise inherit the transparent one. + */ + .inline-chip-trigger { + position: relative; + } + + .inline-chip-trigger[data-loading] { + color: transparent; + } + + .inline-chip-spinner { + /* Set like a glyph of the name, not in the sigil's line box. An inline + SVG's baseline is its bottom edge, so `bottom` offsets from the text + baseline itself; Chromium rounds a font's ascent and descent to whole + px, so any offset from the content box drifts with size, while this + holds at every scale. Tabler's notch inks 20/24 of its box, so 0.75em + draws a 0.625em arc. Centring it on the cap-height midline (`cap` is the + font's own: Inter 0.727em) rests its foot 0.05em above the baseline and + its crown just under the cap height, between the x-height and the + capitals, where an inline glyph reads as part of the word. The `@` + centred it 0.08em lower: its ink descends 0.19em below the baseline. */ + position: relative; + display: inline-block; + vertical-align: baseline; + width: 0.75em; + height: 0.75em; + /* Advances nothing: the transparent `@` keeps the width, and the arc is + centred in its advance. 0.966em is the `@` advance of the token font + (Inter); a percentage cannot replace it, since it would need absolute + positioning, which measures `bottom` from the trigger's content box + (baseline + rounded descent) rather than the baseline, so the arc + would sink by the descent and drift with size. A chip in another font + keeps the arc's height and baseline but may centre it off the `@`. */ + margin-right: -0.75em; + left: calc((-0.966em - 0.75em) / 2); + bottom: calc(0.5cap - 0.375em); + color: var(--text-standard); + animation: inline-chip-spin 1s linear infinite; + } + + @keyframes inline-chip-spin { + to { + transform: rotate(360deg); + } + } + .inline-chip-label { min-width: 0; overflow: hidden; @@ -116,6 +167,10 @@ .inline-chip-qualifier[data-reveal] { animation: none; } + /* A still notch reads as "waiting" without motion. */ + .inline-chip-spinner { + animation: none; + } } /* The editor's wrapper around a chip node. Must not introduce its own box: diff --git a/src/shared/design-system/ui/InlineChip.test.tsx b/src/shared/design-system/ui/InlineChip.test.tsx index b798bcdc52..c813d15e86 100644 --- a/src/shared/design-system/ui/InlineChip.test.tsx +++ b/src/shared/design-system/ui/InlineChip.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import "@testing-library/jest-dom/vitest"; import { act, cleanup, render, screen } from "@testing-library/react"; +import { readFileSync } from "node:fs"; import { afterEach, expect, it, vi } from "vitest"; import { chipFaces, resetChipFaces } from "../chips/faceResolver"; import { InlineChip } from "./InlineChip"; @@ -69,6 +70,71 @@ it("announces unresolved identities without pretending the fallback is a name", ); }); +it("shows a resolving mention as a busy chip whose spinner borrows the sigil's box", () => { + const { container } = render( + , + ); + const chip = screen.getByRole("img", { name: "Resolving mention Example" }); + expect(chip).toHaveAttribute("aria-busy", "true"); + expect(chip).toHaveAttribute("data-state", "loading"); + // The glyph stays in the flow so the chip keeps its width when it returns. + expect(chip).toHaveTextContent("@Example"); + const trigger = chip.querySelector(".inline-chip-trigger"); + expect(trigger).toHaveAttribute("data-loading"); + expect(trigger?.querySelector(".inline-chip-spinner")).toHaveAttribute( + "aria-hidden", + "true", + ); + // Nothing truthful to preview or open yet, even for an interactive consumer. + expect(container.querySelector("button")).toBeNull(); +}); + +it("settling drops the spinner and the busy state without changing the glyph", () => { + const { rerender } = render( + , + ); + rerender( + , + ); + const chip = screen.getByRole("img", { name: "Person Example" }); + expect(chip).not.toHaveAttribute("aria-busy"); + expect(chip.querySelector(".inline-chip-spinner")).toBeNull(); + expect(chip.querySelector(".inline-chip-trigger")).not.toHaveAttribute( + "data-loading", + ); + expect(chip).toHaveTextContent("@Example"); +}); + +// jsdom does not load chips.css, so the stylesheet's side of the contract is +// checked as text: the hidden glyph keeps its box, and reduced motion stills +// the spinner. +it("styles the resolving sigil transparent and stills the spinner under reduced motion", () => { + const css = readFileSync( + "src/shared/design-system/styles/chips.css", + "utf8", + ).replace(/\/\*[\s\S]*?\*\//g, ""); + expect(css).toMatch( + /\.inline-chip-trigger\[data-loading\]\s*\{[^}]*color:\s*transparent;/, + ); + const reduced = css.match( + /@media \(prefers-reduced-motion: reduce\)\s*\{([\s\S]*?)\n {2}\}/, + ); + expect(reduced?.[1]).toMatch( + /\.inline-chip-spinner\s*\{[^}]*animation:\s*none;/, + ); +}); + it("keeps an accessible qualifier separate from the authored name and stays inert", () => { const { container } = render( {Icon ?

`} + > +
+

+ Ask{" "} + {" "} + while the recipient is still being decided. +

+

+ The spinner sits in the sigil's box, so the chip does not move + when it settles into a recipient or back into text. +

+
+ +