diff --git a/docs/agents.md b/docs/agents.md index 9603448b9..cf3c0a443 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -97,8 +97,10 @@ not complete: automatic-recipient inference must honor `complete`, and automatic saved-template resolution must wait for required pending identity/roster evidence. Action policy stays explicit: ordinary member mentions use the channel roster and -never acquire template archive gates. Ordinary nonmember enrollment admits managed -same-community identities; session invitations also allow existing legacy choices. +hide known-archived identities without requiring verified non-archived evidence. +Ordinary nonmember mentions also offer people from the selected community directory +and eligible managed agents. Send asks before adding them; selection grants no +access. Session invitations retain their existing rules, including legacy choices. Templates additionally require verified non-archived state, and legacy-only choices need visible community membership. Save-as-template discloses an incomplete inferred lineup when either inventory or roster evidence is partial; it never claims a full @@ -119,13 +121,75 @@ thread composers through the shared conversation tool contract. The host retains recipient intent, inline editing and avatar removal even when the chooser plugin is disabled. 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. Editing a selected span removes its -notification intent. Native beforeinput ranges preserve untouched spans; missing +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 +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 selected together receive visible key qualifiers; editing a selected span removes its notification intent. Chips remain available without the Mentions chooser. +### Chooser rules + +Both the toolbar picker and inline completion use `mention-candidates.ts` and +`mention-ranking.ts`. Membership permits notification, not a promise that an agent +will accept or answer the prompt. DMs do not gain outside recipients. Ordinary +nonmember consent and session invitation rules remain the access owners; +selection itself neither grants access nor starts an agent. Invalid recipient +keys, known-archived identities, and archived/read-only destinations are excluded. +The viewer is never hidden from themself. Unknown archive state does not block +selection. Optional archive reads are lazy. + +Search trims and lowercases the query. Members precede nonmembers, with humans and agents in each group. Within each +group, matches against the visible resolved label come first: whole-name exact, +name prefix, whole-word exact, then word prefix. Base names and known aliases are +fallback matches in that same order. Only resolved names and real profile/agent +names are searchable. Public keys (including unnamed identity fallbacks) are not +completion matches. Arbitrary name substrings do not match. A hidden base-name match never +promotes a weaker visible-label match. When visible-label match quality ties, +base-name/alias match quality breaks the tie before recipient preferences. +Among equal matches, agents with profile-reported ownership by the viewer come +first, before humans and other agents. Agents that share a base name then form one +block, placed at the first of their case-insensitive displayed labels (including +disambiguating suffixes). Inside a block, explicit-choice recency, managed status, +and already-known online/away status decide the order. Humans never join a block +and sort first on an equal label. Remaining ties use the displayed label, then the +full key. Each rule is a per-choice sort key, so the order is the same for any +input order. Ownership comes +from profile owner metadata, not a name or presence in the saved library. +Recency stays in memory per session/destination and is bounded to 100 destinations +and 100 recipients each. Neither ownership nor recency overrides membership or +match quality. + +An open query installs at most 50 keys. Their order and membership stay fixed until +the query changes or the chooser reopens. Labels, insertion names and availability +remain live. A removed/archived row stays disabled in place; Enter/Tab cannot fall +through to sending. A member who becomes an outside invitation choice also stays +disabled until reopening. Retry refreshes evidence, not the installed order. New +arrivals need a changed query or reopening. Pending sources or missing profiles do +not freeze a premature empty result. Local sources (members and agent choices) +establish the list; the community directory never gates it. Directory people +append below the rows already shown, so a late page never moves a visible row. +While a new query waits or loads, still-matching people from the last settled +page of the same chooser stay visible (one picker, or one inline `@` token; inline +completion remounts per keystroke, so the page is kept per session outside it) and the chooser shows "Searching community…". Uncached queries +reach the network only after a 200 ms typing pause. Settled first pages are cached +per session and query (100 queries); errors are not cached, and Retry reads the +current query again. Identity naming uses eligible candidates plus the +current draft recipients, not every cached profile. + +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. + +The composer rejects already-known archived recipients (never the viewer) at send entry and omits +ineligible agents from the next draft. This is not an archive transaction: archive +changes during enrollment, dispatch or retry are intentionally not covered. The +existing relay membership/send/retry validator is unchanged. + After an accepted send, the next draft starts with the exact selected agent-name mentions, deduplicated by key. Agent classification uses already-cached profile hints or the shared agent-choice projection (legacy and native), not a new lookup or @@ -140,12 +204,16 @@ of relay delivery or agent execution. The picker supports Up/Down navigation, Enter selection and Escape dismissal. -`session.messages.send/reply` accepts up to 32 exact pubkeys and emits deduplicated -`p` tags. Selection never invites someone. The native local-agent flow now offers -same-community managed agents too: the composer enrolls a selected nonmember on -Send, verifies the roster, then calls this unchanged message API. See -[local agent controls](agent-control.md#normal-desktop-workflow). Ordinary nonmember -people are not automatically added. Current roster membership is checked at +`session.messages.send/reply` accepts up to 32 exact notification pubkeys and emits +deduplicated `p` tags. In ordinary channels, Send pauses for selected nonmembers: +**Invite** grants access only with permission and explicit consent, waits +for confirmed membership, then sends notifications. **Do nothing** (or **Send +anyway** without add permission) sends those identities as separate `mention` +reference tags, without adding or notifying them. Close or Escape keeps the draft. Existing member mentions still notify in a +mixed send. Reference keys are validated and bounded to 32. Selection itself never +invites or starts anyone; confirmed outgoing notifications own agent wakeup. See +[local agent controls](agent-control.md#normal-desktop-workflow). DM and session +admission paths remain separate. Current notification-recipient membership is checked at intent, before signing, and after signing before entering the transport publisher; retry/restored signed intent uses the same publisher check. Before **each** mention publication the session performs a bounded foreground finite read of this @@ -168,7 +236,8 @@ not a substitute for relay authorization, a membership transaction, or the ACP listener's own admission rules. Network changes after transport dispatch remain possible. No ownership or running status is inferred from a member's name/profile. -Wire compatibility is kind 9 + `h` + exact `p`; direct replies also carry +Wire compatibility is kind 9 + `h` + exact `p` for notifications and `mention` +for reference-only identities; direct replies also carry `["e", root, "", "reply"]`. Existing buzz-acp owns mention admission, replay, channel membership, pool wake and harness execution. This slice adds no wake loop, process launcher, configuration save or agent invitation operation. The local library and archive display are not mention authorization. diff --git a/src/bundled/composer/lab/fixture.tsx b/src/bundled/composer/lab/fixture.tsx index b4a2f2279..9381cea21 100644 --- a/src/bundled/composer/lab/fixture.tsx +++ b/src/bundled/composer/lab/fixture.tsx @@ -78,6 +78,10 @@ const rawSession = { archives: { snapshot: () => archiveSnapshot, subscribe: empty, + // Archive reads are unavailable here, as the real contract reports them. + state: () => "unknown" as const, + ensure: async () => {}, + refresh: async () => {}, }, emoji: { snapshot: () => emojiSnapshot, diff --git a/src/bundled/identity-naming/README.md b/src/bundled/identity-naming/README.md index 023add32b..8dc8d9b8b 100644 --- a/src/bundled/identity-naming/README.md +++ b/src/bundled/identity-naming/README.md @@ -277,7 +277,7 @@ different case-folding rule is a different policy, not byte-identical conformanc [policy.test.ts](../../features/identity-names/policy.test.ts). - Context and fact selection: [directory.ts](../../features/identity-names/directory.ts), [react.ts](../../features/identity-names/react.ts), and - [mention-choices.ts](../mentions/mention-choices.ts). + [use-mention-choices.ts](../mentions/use-mention-choices.ts). - buzz-app feature-contract precedent: [Profiles](../../../docs/profiles.md) and [Unread](../../../docs/unread.md), especially explicit consumer and authority boundaries. - block/buzz, inspected at `797012ff01a6d499959b45ed2e56f7927c6a4d6b`: diff --git a/src/bundled/mentions/MentionCompletion.tsx b/src/bundled/mentions/MentionCompletion.tsx index 7a2440adc..e044036f6 100644 --- a/src/bundled/mentions/MentionCompletion.tsx +++ b/src/bundled/mentions/MentionCompletion.tsx @@ -1,72 +1,44 @@ -import { useMentionDirectory } from "./useMentionDirectory"; -import { DraftMentionRoster } from "../../features/messages/draft-mention-roster"; -import { availableMentionAgents } from "../../features/agents/mention-choices"; -import { mentionChoices } from "./mention-choices"; -import { useArchivedPredicate } from "../../features/relay/use-archived"; -import { useIdentityNames } from "../../features/identity-names/react"; -import { useAgentChoices } from "../../features/agents/use-choices"; -import { - useContext, - useEffect, - useMemo, - useState, - useSyncExternalStore, -} from "react"; +import { useEffect, useState } from "react"; +import { useMentionChoices } from "./use-mention-choices"; import type { ComposerCompletionProps } from "../../features/conversation/contracts"; import type { RelaySession } from "../../features/relay/session"; import { Avatar } from "../../shared/design-system/ui/Avatar"; -import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { matchesMentionQuery } from "./mention-query"; -import { peopleOrder } from "../../features/profiles/people-order"; // Demand bookkeeping only, not another profile cache. Missing names do not issue // the same network request on every query keystroke; explicit retry remains available. const demands = new WeakMap>(); export function MentionCompletion({ session, + scope, channelId, + threadRootId, inviteAgents, query, publish, }: ComposerCompletionProps) { - const draftRoster = useContext(DraftMentionRoster); - const resolveName = useIdentityNames(session.names); - const archived = useArchivedPredicate(session); - const list = useSyncExternalStore( - session.channels.subscribeList, - session.channels.list, - session.channels.list, - ); - const profiles = useSyncExternalStore( - session.profiles.subscribe, - session.profiles.snapshot, - session.profiles.snapshot, - ); - const agents = useAgentChoices(session, !!inviteAgents); - const agentPubkeys = useKnownAgentPubkeys(session, profiles); - const channel = list.channels.find((item) => item.id === channelId); - const directory = useMentionDirectory( + // The host remounts this provider on every keystroke. One `@` token in one + // composer is the chooser lifetime that keeps the last directory page. + const model = useMentionChoices( session, - channel, + channelId, + inviteAgents, query.query, - true && !draftRoster && !inviteAgents, - ); - const available = useMemo( - () => - availableMentionAgents( - channel, - agents.identities, - inviteAgents, - session.outbox?.supports(9000), - ), - [channel, agents, session.outbox, inviteAgents], + JSON.stringify(["inline", scope, channelId, threadRootId, query.start]), ); + const { + profiles, + agents, + channel, + list, + choices, + roster: draftRoster, + } = model; + const members = draftRoster?.map((p) => p.pubkey) ?? channel?.members ?? []; + const memberKey = members.join(":"); const parentAdmission = !!channel && (channel.channelType !== "session" || !!channel.parentChannelId); - const members = - draftRoster?.map((person) => person.pubkey) ?? channel?.members ?? []; - const memberKey = members.join(":"); const [attempt, retry] = useState(0); const [error, setError] = useState(false); useEffect(() => { @@ -90,64 +62,48 @@ export function MentionCompletion({ }, [session, memberKey, attempt, draftRoster]); useEffect(() => { const members = memberKey ? memberKey.split(":") : []; - const candidates = mentionChoices( - draftRoster ?? [ - ...(inviteAgents ? agents.identities : []), - ...available, - ...directory.people, - ], - members, - profiles, - resolveName, - archived, - ); - const needle = query.query.toLowerCase(); - // Source names close completed mentions; display labels still admit multi-word searches. const admitted = matchesMentionQuery( query.query, - candidates.flatMap(({ recipient, label }) => [recipient.name, label]), + [...model.candidates, ...choices].flatMap((c) => [...c.aliases, c.label]), ); - const order = peopleOrder(query.query); - const matching = - admitted && !channel?.archived - ? candidates - .filter(({ recipient, label }) => - `${label} ${recipient.pubkey}`.toLowerCase().includes(needle), - ) - .sort((a, b) => - order( - { name: a.label, pubkey: a.recipient.pubkey }, - { name: b.label, pubkey: b.recipient.pubkey }, - ), - ) - : []; + const matching = admitted ? choices : []; const membershipMissing = !draftRoster && (!inviteAgents || !!channel) && !channel?.members; const membershipError = !draftRoster && list.error; const missing = !draftRoster && members.some((key) => !profiles.has(key)); // A multi-word query that continues no known name is prose, not a search. - if (!admitted && !directory.loading && !directory.error) { + if ( + !admitted && + !model.pending && + !model.directory.loading && + !model.directory.error + ) { const withdraw = publish({ items: [] }); return () => { if (withdraw) withdraw(); }; } const withdraw = publish({ - items: matching.slice(0, 20).map(({ recipient, label }) => ({ + spaceId: model.spaceId, + items: matching.map(({ recipient, label, disabled }) => ({ + disabled, + canSelect: (key) => model.canSelect(recipient.pubkey, key === " "), id: recipient.pubkey, label, - detail: members.includes(recipient.pubkey) - ? recipient.pubkey - : inviteAgents - ? `${parentAdmission ? "Adds to session and parent channel" : "Adds to session"} · ${recipient.pubkey}` - : "Not in channel · Choose whether to add when you send", + detail: + disabled ?? + (members.includes(recipient.pubkey) + ? recipient.pubkey + : inviteAgents + ? `${parentAdmission ? "Adds to session and parent channel" : "Adds to session"} · ${recipient.pubkey}` + : "Not in channel · Choose whether to add when you send"), preview: ( person.pubkey === recipient.pubkey, )?.picture ?? "", @@ -155,10 +111,8 @@ export function MentionCompletion({ )} size="default" shape={ - agentPubkeys.has(recipient.pubkey) || - directory.people.some( - (person) => - person.pubkey === recipient.pubkey && person.isAgent, + model.candidates.some( + (c) => c.recipient.pubkey === recipient.pubkey && c.agent, ) ? "squircle" : "circle" @@ -167,25 +121,30 @@ export function MentionCompletion({ ), edit: { mention: recipient }, })), - ...(directory.error - ? { status: directory.error } - : directory.loading - ? { status: "Searching community…" } - : agents.status === "error" || agents.error - ? { status: "Could not load agents. Retry to refresh." } - : admitted && membershipMissing - ? { status: "Channel membership unavailable." } - : admitted && membershipError - ? { status: "Could not refresh channel membership." } - : error || missing - ? { - status: - "Some names unavailable. Exact public keys still identify recipients.", - } - : directory.more || matching.length > 20 - ? { status: "Narrow your search to see more members." } - : {}), - ...(directory.error || + ...(model.pending + ? { status: "Loading recipients…" } + : model.directory.error + ? { status: model.directory.error } + : model.archives.status === "error" + ? { status: "Archive information unavailable. Retry to refresh." } + : agents.status === "error" || agents.error + ? { status: "Could not load agents. Retry to refresh." } + : admitted && membershipMissing + ? { status: "Channel membership unavailable." } + : admitted && membershipError + ? { status: "Could not refresh channel membership." } + : error || missing + ? { + status: + "Some names unavailable. Exact public keys still identify recipients.", + } + : model.directory.loading + ? { status: "Searching community…" } + : model.directory.more || model.truncated + ? { status: "Narrow your search to see more members." } + : {}), + ...(model.directory.error || + model.archives.status === "error" || agents.status === "error" || agents.error || membershipMissing || @@ -194,8 +153,9 @@ export function MentionCompletion({ missing ? { retry: () => { - directory.retry(); - void session.agentChoices.refresh(); + model.directory.retry(); + void session.agentChoices.refresh(!!inviteAgents); + void session.archives?.refresh(); setError(false); retry((value) => value + 1); if (membershipMissing || membershipError) @@ -204,55 +164,24 @@ export function MentionCompletion({ } : {}), }); - // Invalidate displayed choices synchronously, before React paints new data. - const revoke = () => { - if (withdraw) withdraw(); - }; - const rosterChanged = session.channels.subscribeList(() => { - const next = session.channels.list(); - // Other channels' previews and list loading notifications are not new - // evidence for this menu. Revoke only what the effect will republish. - if ( - next.channels.find((item) => item.id === channelId) !== channel || - next.error !== list.error - ) - revoke(); - }); - const profilesChanged = session.profiles.subscribe(() => { - if (session.profiles.snapshot() !== profiles) revoke(); - }); - const namesChanged = session.names.subscribe(revoke); - const agentsChanged = session.agentChoices.subscribe(revoke); return () => { - namesChanged(); - agentsChanged(); - rosterChanged(); - profilesChanged(); - revoke(); + if (withdraw) withdraw(); }; }, [ - directory.retry, - directory.people, - directory.error, - directory.loading, - directory.more, - draftRoster, - resolveName, - archived, session, - agents, - inviteAgents, - channel, - channelId, + publish, + query.query, memberKey, - available, - parentAdmission, + model, profiles, - agentPubkeys, - query.query, - publish, + list, + agents, error, - list.error, + choices, + channel, + draftRoster, + inviteAgents, + parentAdmission, ]); return null; } diff --git a/src/bundled/mentions/MentionPicker.tsx b/src/bundled/mentions/MentionPicker.tsx index f7f00e3ea..559287803 100644 --- a/src/bundled/mentions/MentionPicker.tsx +++ b/src/bundled/mentions/MentionPicker.tsx @@ -1,36 +1,21 @@ -import { useMentionDirectory } from "./useMentionDirectory"; -import { DraftMentionRoster } from "../../features/messages/draft-mention-roster"; import { PopoverRoot, PopoverTrigger, PopoverPopup, } from "../../shared/design-system/ui/Popover"; -import { availableMentionAgents } from "../../features/agents/mention-choices"; -import { mentionChoices } from "./mention-choices"; -import { useArchivedPredicate } from "../../features/relay/use-archived"; -import { useIdentityNames } from "../../features/identity-names/react"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { SearchField } from "../../shared/design-system/ui/SearchField"; import { Button } from "../../shared/design-system/ui/Button"; import { IconButton } from "../../shared/design-system/ui/IconButton"; -import { useAgentChoices } from "../../features/agents/use-choices"; import { Avatar } from "../../shared/design-system/ui/Avatar"; -import { useKnownAgentPubkeys } from "../../features/agents/use-known"; import { AtIcon } from "../../shared/design-system/icons/index"; -import { - useContext, - useEffect, - useMemo, - useRef, - useState, - useSyncExternalStore, -} from "react"; +import { useEffect, useId, useRef, useState } from "react"; +import { useMentionChoices } from "./use-mention-choices"; import type { RelaySession } from "../../features/relay/session"; import "../../shared/design-system/styles/scrollbars.css"; import styles from "./Mentions.module.css"; import type { ComposerToolProps } from "../../features/conversation/contracts"; -import { peopleOrder } from "../../features/profiles/people-order"; /** Select identities from the shared relay roster, never from display-name matching. */ export function MentionPicker({ @@ -47,9 +32,6 @@ export function MentionPicker({ inviteAgents?: boolean | undefined; select: ComposerToolProps["insertMention"]; }) { - const draftRoster = useContext(DraftMentionRoster); - const resolveName = useIdentityNames(session.names); - const archived = useArchivedPredicate(session); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); const [error, setError] = useState(); @@ -57,35 +39,25 @@ export function MentionPicker({ const controls = useRef(null); const accepted = useRef(false); const searchInput = useRef(null); - const list = useSyncExternalStore( - session.channels.subscribeList, - session.channels.list, - session.channels.list, - ); - const profiles = useSyncExternalStore( - session.profiles.subscribe, - session.profiles.snapshot, - session.profiles.snapshot, - ); - const agents = useAgentChoices(session, !!inviteAgents && open); - const agentPubkeys = useKnownAgentPubkeys(session, profiles); - const channel = list.channels.find((item) => item.id === channelId); - const directory = useMentionDirectory( + // The picker stays mounted while closed; each opening is its own lifetime. + const [opening, setOpening] = useState(0); + const lifetime = `picker:${useId()}:${opening}`; + const model = useMentionChoices( session, - channel, + channelId, + inviteAgents, search, - open && !disabled && !draftRoster && !inviteAgents, - ); - const available = useMemo( - () => - availableMentionAgents( - channel, - agents.identities, - inviteAgents, - session.outbox?.supports(9000), - ), - [channel, agents, session.outbox, inviteAgents], + lifetime, + open && !disabled, ); + const { + profiles, + agents, + list, + channel, + roster: draftRoster, + choices: candidates, + } = model; const parentAdmission = !!channel && (channel.channelType !== "session" || !!channel.parentChannelId); @@ -107,35 +79,13 @@ export function MentionPicker({ current = false; }; }, [session, open, memberKey, draftRoster]); - const order = peopleOrder(search); - const candidates = mentionChoices( - draftRoster ?? [ - ...(inviteAgents ? agents.identities : []), - ...available, - ...directory.people, - ], - members ?? [], - profiles, - resolveName, - archived, - ) - .filter(({ recipient, label }) => - `${label} ${recipient.pubkey}` - .toLowerCase() - .includes(search.trim().toLowerCase()), - ) - .sort((a, b) => - order( - { name: a.label, pubkey: a.recipient.pubkey }, - { name: b.label, pubkey: b.recipient.pubkey }, - ), - ); return ( { setOpen(next); if (next) { + setOpening((value) => value + 1); accepted.current = false; if (!draftRoster) session.channels.ensureList(); } @@ -244,25 +194,39 @@ export function MentionPicker({ : "Agents you mention join this session when you send, with access to its history."}

)} + {model.directory.loading && ( +

Searching community…

+ )} + {model.directory.more && ( +

Narrow your search to find more community people.

+ )} + {model.directory.error && ( + <> +

{model.directory.error}

+ + + )} {agents.status === "loading" && (

Loading agents…

)} {(agents.status === "error" || !!agents.error) && ( )} - {directory.loading &&

Searching community…

} - {directory.more && ( -

Narrow your search to find more community people.

- )} - {directory.error &&

{directory.error}

} - {directory.error && ( - )} {error &&

{error}

} @@ -282,64 +246,65 @@ export function MentionPicker({ )}
- {candidates.slice(0, 100).map(({ recipient, label }) => ( - { - if (select(recipient)) { - accepted.current = true; - setOpen(false); - } - }} - label={ - - {label} - {!members?.includes(recipient.pubkey) && ( - - {inviteAgents - ? parentAdmission - ? "Adds to session and parent channel when you send" - : "Adds to session when you send" - : "Not in channel · Choose whether to add when you send"} - - )} - - } - title={recipient.pubkey} - trailing={{recipient.pubkey.slice(0, 12)}} - icon={ - person.pubkey === recipient.pubkey, - )?.picture ?? - "", - "small", - )} - size="large" - shape={ - agentPubkeys.has(recipient.pubkey) || - directory.people.some( - (person) => - person.pubkey === recipient.pubkey && - person.isAgent, - ) - ? "squircle" - : "circle" + {candidates.map( + ({ recipient, label, agent, disabled: reason }) => ( + { + if ( + model.canSelect(recipient.pubkey) && + select(recipient) + ) { + accepted.current = true; + setOpen(false); } - /> - } - /> - ))} - {candidates.length > 100 && ( + }} + label={ + + {label} + {reason && {reason}} + {!members?.includes(recipient.pubkey) && ( + + {inviteAgents + ? parentAdmission + ? "Adds to session and parent channel when you send" + : "Adds to session when you send" + : "Not in channel · Choose whether to add when you send"} + + )} + + } + title={recipient.pubkey} + trailing={{recipient.pubkey.slice(0, 12)}} + icon={ + person.pubkey === recipient.pubkey, + )?.picture ?? + "", + "small", + )} + size="large" + shape={ + agent || !members?.includes(recipient.pubkey) + ? "squircle" + : "circle" + } + /> + } + /> + ), + )} + {model.truncated && (

Narrow your search to see more members.

)} {members && !candidates.length && ( diff --git a/src/bundled/mentions/mention-choices.ts b/src/bundled/mentions/mention-choices.ts deleted file mode 100644 index 6a909f6e7..000000000 --- a/src/bundled/mentions/mention-choices.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { MentionRecipient } from "../../features/messages/mention-draft"; -import type { Profile } from "../../features/relay/contracts"; -import type { NamingIdentity } from "../../features/identity-names/policy"; - -/** Keep wire names recognizable by the sent-message binder; resolve only UI labels. */ -export function mentionChoices( - agents: readonly MentionRecipient[], - members: readonly string[], - profiles: ReadonlyMap, - resolveName: ( - pubkey: string, - fallback: string, - candidates?: readonly string[], - displayFacts?: readonly NamingIdentity[], - ) => string, - archived: (pubkey: string) => boolean = () => false, -) { - const choices = new Map( - agents.map(({ pubkey, name }) => [pubkey, { pubkey, name }]), - ); - for (const pubkey of members) - choices.set(pubkey, { - pubkey, - name: - profiles.get(pubkey)?.name ?? - choices.get(pubkey)?.name ?? - pubkey.slice(0, 12), - }); - // Base Buzz hides archived identities from autocomplete and member-add. - for (const pubkey of choices.keys()) - if (archived(pubkey)) choices.delete(pubkey); - const candidates = [...choices.keys()]; - // Directory people may have no cached profile. Supply their names so namesakes - // in this choice set are qualified; known names keep their own sources. - const facts = agents - .filter( - ({ pubkey }) => !members.includes(pubkey) && !resolveName(pubkey, ""), - ) - .map(({ pubkey, name }) => ({ pubkey, name })); - return [...choices.values()].map((recipient) => ({ - recipient, - label: resolveName(recipient.pubkey, recipient.name, candidates, facts), - })); -} diff --git a/src/bundled/mentions/mention-ranking.test.ts b/src/bundled/mentions/mention-ranking.test.ts new file mode 100644 index 000000000..d521537e0 --- /dev/null +++ b/src/bundled/mentions/mention-ranking.test.ts @@ -0,0 +1,230 @@ +import { expect, it } from "vitest"; +import { npubEncode } from "nostr-tools/nip19"; +import { + exactMention, + rankMentions, + mentionMatch, + type MentionChoice, +} from "./mention-ranking"; +const choice = ( + key: string, + name: string, + extra: Partial = {}, +): MentionChoice => ({ + recipient: { pubkey: key.repeat(64), name }, + label: name, + aliases: [name], + member: true, + agent: false, + managed: false, + owned: false, + ...extra, +}); +it("groups before relevance, then exact/prefix/word/word-prefix with deterministic ties", () => { + const rows = [ + choice("a", "A Honey"), + choice("b", "Honey Bee"), + choice("c", "Honey"), + choice("d", "Honey", { member: false, agent: true }), + choice("e", "A Honeybee"), + ]; + expect(rankMentions(rows, "honey").map((c) => c.recipient.pubkey[0])).toEqual( + ["c", "b", "a", "e", "d"], + ); + expect(mentionMatch(choice("a", "A Honey"), "oney")).toBe(Infinity); +}); +it("ranks resolved label matches before base names and aliases", () => { + const other = choice("a", "Honey", { + label: "Wes’s Honey", + aliases: ["Honey", "Legacy Bee"], + agent: true, + }); + const mine = choice("b", "Honey", { agent: true, managed: true }); + expect(rankMentions([other, mine], "Honey")[0]).toBe(mine); + expect(rankMentions([other, mine], "Wes’s")).toEqual([other]); + expect(rankMentions([other, mine], "Legacy")).toEqual([other]); + expect(mentionMatch(other, "Honey")).toBe(2); + const visible = choice("c", "Another name", { label: "Visible Legacy Bee" }); + expect(rankMentions([other, visible], "Legacy")).toEqual([visible, other]); + expect(rankMentions([other, visible], "Legacy Bee")).toEqual([other]); + const qualified = choice("d", "Rizz", { + label: "tho’s Rizz", + agent: true, + owned: true, + }); + const displayed = choice("e", "Rizz", { agent: true }); + expect(rankMentions([qualified, displayed], "riz")).toEqual([ + displayed, + qualified, + ]); +}); +it("history only breaks equally matched same-name agent ties, followed by managed and presence", () => { + const a = choice("a", "Honey", { agent: true }), + b = choice("b", "Honey", { agent: true, managed: true }); + expect( + rankMentions([a, b], "Honey", new Map([[a.recipient.pubkey, 1]])), + ).toEqual([a, b]); + const prefix = choice("c", "Honey Bee", { agent: true }); + expect( + rankMentions( + [a, prefix], + "Honey", + new Map([[prefix.recipient.pubkey, 100]]), + ), + ).toEqual([a, prefix]); + expect( + rankMentions([a, { ...b, managed: false }], "Honey", undefined, (k) => + k === b.recipient.pubkey ? "online" : "away", + )[0]?.recipient.pubkey, + ).toBe(b.recipient.pubkey); +}); +it("Space requires a unique exact full-set match, not a rank winner or short-name prefix", () => { + const a = choice("a", "Honey"), + b = choice("b", "Honey", { label: "Wes’s Honey" }); + expect(exactMention([a], "HONEY")).toBe(a.recipient.pubkey); + expect(exactMention([a], "Hon")).toBeUndefined(); + expect(exactMention([a, b], "Honey")).toBeUndefined(); + expect(exactMention([a, b], "Wes’s Honey")).toBe(b.recipient.pubkey); + expect(exactMention([a, choice("c", "Honey Bee")], "Honey")).toBeUndefined(); +}); + +it("prefers owned agents on equal matches before history, management and presence, not membership or relevance", () => { + const other = choice("a", "Rizz", { agent: true, managed: true }); + const mine = choice("b", "Rizz", { agent: true, owned: true }); + const history = new Map([[other.recipient.pubkey, 10]]); + const presence = (key: string) => + key === other.recipient.pubkey ? "online" : "unknown"; + expect(rankMentions([other, mine], "riz", history, presence)).toEqual([ + mine, + other, + ]); + expect(rankMentions([other, { ...mine, member: false }], "riz")[0]).toBe( + other, + ); + expect( + rankMentions( + [other, choice("c", "Rizz Helper", { agent: true, owned: true })], + "Rizz", + )[0], + ).toBe(other); + const differentName = choice("c", "Rizz Helper", { + agent: true, + owned: true, + }); + expect(rankMentions([other, differentName], "riz")).toEqual([ + differentName, + other, + ]); + expect(exactMention([other, mine], "Rizz")).toBeUndefined(); +}); + +it("breaks visible match ties by base-name quality, then the full displayed label", () => { + const rows = [ + choice("3", "Fast Fizz", { agent: true }), + choice("0", "Fizz", { agent: true, label: "baxen’s Fizz · oncp" }), + choice("4", "Fizz", { agent: true, label: "baxen’s Fizz · s03j" }), + choice("5", "Fizz", { agent: true, label: "Kenny Lopez’s Fizz" }), + choice("b", "Fizz", { agent: true, label: "baxen’s Fizz · 4prr" }), + choice("c", "Fizz", { agent: true, label: "baxen’s Fizz · 06pl" }), + ]; + const expected = [rows[5], rows[4], rows[1], rows[2], rows[3], rows[0]]; + expect(rankMentions(rows, "fizz")).toEqual(expected); + expect(rankMentions([...rows].reverse(), "FIZZ")).toEqual(expected); + const alias = choice("d", "Another name", { + label: "Other Fizz", + aliases: ["Another name", "Fizz"], + }); + expect(rankMentions([choice("3", "Fast Fizz"), alias], "fizz")[0]).toBe( + alias, + ); +}); +it("sorts equal matches by case-insensitive displayed labels and only then keys", () => { + const a = choice("a", "Hidden A", { label: "Zoe", aliases: ["common"] }); + const b = choice("b", "Hidden B", { label: "amy", aliases: ["common"] }); + const c = choice("c", "Hidden C", { label: "Amy", aliases: ["common"] }); + expect(rankMentions([c, a, b], "common")).toEqual([b, c, a]); + expect(rankMentions([c, a, b], "")).toEqual([b, c, a]); +}); + +it("never matches hex or npub keys, including short substrings", () => { + const key = + "150b20bdf6130418df9239dd1bd082c71612c8d653b47c277200365b9be215dc"; + const row = choice("a", "Bad Janet", { + recipient: { pubkey: key, name: "Bad Janet" }, + }); + const npub = npubEncode(key); + for (const query of [ + "h", + key, + key.slice(0, 12), + key.slice(-8), + npub, + npub.slice(0, 15), + npub.slice(-6), + ]) { + expect(rankMentions([row], query), query).toEqual([]); + expect(exactMention([row], query), query).toBeUndefined(); + } + expect(rankMentions([row], "jan")).toEqual([row]); +}); + +it("does not search unnamed identity fallback labels or commit them with Space", () => { + const row = choice("a", "aaaaaaaaaaaa", { aliases: [] }); + expect(rankMentions([row], "")).toEqual([row]); + expect(rankMentions([row], "aaa")).toEqual([]); + expect(exactMention([row], "aaaaaaaaaaaa")).toBeUndefined(); +}); + +it("ranks outside humans and agents in one relevance group", () => { + const human = choice("a", "Honey", { member: false }); + const agent = choice("b", "Honey Bee", { + member: false, + agent: true, + owned: true, + }); + const member = choice("c", "A Honey"); + expect(rankMentions([agent, human, member], "honey")).toEqual([ + member, + human, + agent, + ]); +}); +const permutations = (items: readonly T[]): T[][] => + items.length < 2 + ? [[...items]] + : items.flatMap((item, i) => + permutations([...items.slice(0, i), ...items.slice(i + 1)]).map( + (rest) => [item, ...rest], + ), + ); +it("orders mixed people and agents the same way from every input order", () => { + // Review case: owned Zed, unowned Alpha, human Mary. A pairwise agent-only + // ownership rule made Zed < Alpha < Mary < Zed, a cycle. + const zed = choice("a", "Zed", { agent: true, owned: true }); + const alpha = choice("b", "Alpha", { agent: true }); + const mary = choice("c", "Mary"); + for (const rows of permutations([zed, alpha, mary])) + expect(rankMentions(rows, "")).toEqual([zed, alpha, mary]); + // Same-name agents stay one block at its first label. Recency, managed and + // presence order only that block, never a person or another name between. + const recent = choice("d", "Honey", { agent: true, label: "Honey · zz" }); + const managed = choice("e", "Honey", { + agent: true, + managed: true, + label: "Wes’s Honey", + }); + const online = choice("f", "Honey", { agent: true, label: "Honey · aa" }); + const human = choice("1", "Honey · b"); + const other = choice("2", "Honey Bee", { agent: true, label: "Honey · c" }); + const history = new Map([[recent.recipient.pubkey, 2]]); + const presence = (key: string) => + key === online.recipient.pubkey ? "online" : "unknown"; + for (const rows of permutations([recent, managed, online, human, other])) + expect(rankMentions(rows, "", history, presence)).toEqual([ + recent, + managed, + online, + human, + other, + ]); +}); diff --git a/src/bundled/mentions/mention-ranking.ts b/src/bundled/mentions/mention-ranking.ts new file mode 100644 index 000000000..b46840f38 --- /dev/null +++ b/src/bundled/mentions/mention-ranking.ts @@ -0,0 +1,108 @@ +import type { mentionCandidates } from "../../features/messages/mention-candidates"; +export type MentionChoice = ReturnType[number] & { + label: string; +}; +const normalized = (text: string) => text.trim().toLowerCase(); +const compare = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0); +function nameMatch(text: string, needle: string) { + if (!needle) return 0; + const name = normalized(text); + const words = name.split(/\s+/u); + return name === needle + ? 0 + : name.startsWith(needle) + ? 1 + : words.includes(needle) + ? 2 + : words.some((word) => word.startsWith(needle)) + ? 3 + : Infinity; +} +function baseMatch(choice: MentionChoice, query: string) { + return Math.min( + ...choice.aliases.map((name) => nameMatch(name, normalized(query))), + ); +} +export function mentionMatch(choice: MentionChoice, query: string) { + const needle = normalized(query); + if (!needle) return 0; + // No known name means the displayed label is only an identity fallback. + if (!choice.aliases.length) return Infinity; + // Rank what the user sees before other real names for the same identity. + const visible = nameMatch(choice.label, needle); + if (Number.isFinite(visible)) return visible; + return 4 + baseMatch(choice, query); +} +/** + * Sorts by per-choice keys, so the order is transitive for any mix of people + * and agents. After match quality, your own agents come first. Agents that + * share a name form one block at its first visible label; recency, managed, + * and presence order only that block. + */ +export function rankMentions( + choices: readonly MentionChoice[], + query: string, + history?: ReadonlyMap, + presence: (key: string) => string = () => "unknown", +) { + const keyed = choices + .filter((c) => Number.isFinite(mentionMatch(c, query))) + .map((c) => { + const tier = [ + c.member ? 0 : 1, + mentionMatch(c, query), + baseMatch(c, query), + c.agent && c.owned ? 0 : 1, + ]; + return { + c, + tier, + label: normalized(c.label), + // People never join a block. On an equal label they sort first. + block: c.agent + ? `1${JSON.stringify([tier, normalized(c.recipient.name)])}` + : `0${c.recipient.pubkey}`, + recent: history?.get(c.recipient.pubkey) ?? 0, + online: { online: 0, away: 1 }[presence(c.recipient.pubkey)] ?? 2, + }; + }); + const first = new Map(); + for (const k of keyed) { + const label = first.get(k.block); + if (label === undefined || k.label < label) first.set(k.block, k.label); + } + const blockLabel = (k: (typeof keyed)[number]) => first.get(k.block) ?? ""; + return keyed + .sort( + (a, b) => + a.tier.reduce((d, value, i) => d || value - (b.tier[i] ?? 0), 0) || + compare(blockLabel(a), blockLabel(b)) || + compare(a.block, b.block) || + b.recent - a.recent || + Number(b.c.managed) - Number(a.c.managed) || + a.online - b.online || + compare(a.label, b.label) || + compare(a.c.recipient.pubkey, b.c.recipient.pubkey), + ) + .map((k) => k.c); +} +/** Space is intent only for one exact key across the full, uncapped choice set. */ +export function exactMention(choices: readonly MentionChoice[], query: string) { + const needle = normalized(query); + if (!needle) return; + const matches = choices.filter( + (c) => + c.aliases.length > 0 && + [...c.aliases, c.label].some((name) => normalized(name) === needle), + ); + if ( + matches.length !== 1 || + choices.some((c) => + [...c.aliases, c.label].some((name) => + normalized(name).startsWith(`${needle} `), + ), + ) + ) + return; + return matches[0]?.recipient.pubkey; +} diff --git a/src/bundled/mentions/session-agents.test.tsx b/src/bundled/mentions/session-agents.test.tsx index 4aef1fc3a..d36fffa55 100644 --- a/src/bundled/mentions/session-agents.test.tsx +++ b/src/bundled/mentions/session-agents.test.tsx @@ -16,6 +16,15 @@ import { controlFixture } from "../../features/agents/control-testing"; import type { RelaySession } from "../../features/relay/session"; import { MentionPicker } from "./MentionPicker"; import { MentionCompletion } from "./MentionCompletion"; +import { ComposerCompletions } from "../../features/conversation/ComposerCompletions"; +import type { + ComposerCompletion, + ComposerObservation, +} from "../../features/conversation/contracts"; +import type { CompletionEditor } from "../../features/conversation/useCompletionEditor"; +import type { ComposerInputElement } from "../../features/messages/composer-dom"; +import type { Contribution } from "../../plugins/contributions"; +import { mentionQuery } from "./mention-query"; import { createAgentChoices } from "../../features/agents/choices"; import { createAgentLibrary } from "../../features/agents/library"; import type { CompletionResult } from "../../features/conversation/contracts"; @@ -76,6 +85,13 @@ function setup(parent: boolean | null = true, archived = false) { return () => listeners.delete(listener); }, ensure: async () => {}, + refresh: async () => {}, + state: (pubkey: string) => + archiveSnapshot.status !== "ready" + ? ("unknown" as const) + : archiveSnapshot.archived.includes(pubkey) + ? ("archived" as const) + : ("not-archived" as const), }, channels: { list: () => list, @@ -134,14 +150,18 @@ it("uses the same alphabetical and prefix ordering for typed and button mentions (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( (item) => item.label, ); - expect(completionNames()).toEqual(["Adam Avery", "Avery", "Zoe"]); + await waitFor(() => + expect(completionNames()).toEqual(["Adam Avery", "Avery", "Zoe"]), + ); view.rerender( , ); - expect(completionNames()).toEqual(["Avery", "Adam Avery"]); + await waitFor(() => + expect(completionNames()).toEqual(["Avery", "Adam Avery"]), + ); view.unmount(); render( { +it("prioritizes the viewer-owned namesake in both menus and selects the exact owner-labeled recipient", async () => { const test = setup(); const viewer = "1".repeat(64), owner = "2".repeat(64), @@ -745,6 +766,7 @@ it("selects the exact recipient behind a context-aware owner label", async () => }; const session = { ...test.session, + viewer, profiles: { snapshot: () => profiles, subscribe: () => () => {}, @@ -758,6 +780,23 @@ it("selects the exact recipient behind a context-aware owner label", async () => { viewer, profiles: session.profiles, agentLibrary: library.queries }, { snapshot: () => [provider], subscribe: () => () => {} }, ); + const publish = vi.fn(); + const completion = render( + , + ); + expect( + (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( + (item) => item.id, + ), + ).toEqual([mine, test.key]); + completion.unmount(); const select = vi.fn(() => true); const view = render( ); const user = userEvent.setup(); await user.click(screen.getByRole("button", { name: "Mention a member" })); + await user.type(screen.getByRole("searchbox"), "hon"); + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")) + .filter((label) => label?.endsWith(mine) || label?.endsWith(test.key)), + ).toEqual([`Honey ${mine}`, `Wes’s Honey ${test.key}`]); await user.click( await screen.findByRole("button", { name: `Wes’s Honey ${test.key}` }), ); @@ -989,6 +1035,103 @@ it.each(["dm", "session"] as const)( }, ); +it("shows local rows before the directory, appends outside rows, and reuses settled pages", async () => { + const t = setup(); + const lara = { pubkey: "e".repeat(64), name: "Lara" }; + const larry = { pubkey: "f".repeat(64), name: "Larry Outside" }; + const pages = new Map< + string, + (value: { + people: { pubkey: string; name: string }[]; + hasMore: boolean; + }) => void + >(); + const people = vi.fn( + (query: string) => + new Promise<{ + people: { pubkey: string; name: string }[]; + hasMore: boolean; + }>((resolve) => pages.set(query, resolve)), + ); + const profiles = new Map([[t.member, { name: "Larkin" }]]); + const session = { + ...t.session, + directMessages: { ...t.session.directMessages, people }, + profiles: { ...t.session.profiles, snapshot: () => profiles }, + }; + const publish = vi.fn(); + const props = { + session, + scope: "test", + channelId: "parent", + observation: { revision: 1, text: "@La", start: 3, end: 3 }, + publish, + }; + const last = () => publish.mock.lastCall?.[0] as CompletionResult | undefined; + const labels = () => last()?.items.map((item) => item.label); + const view = render( + , + ); + // The member is usable before the network search starts or settles. + await waitFor(() => expect(labels()).toEqual(["Larkin"])); + expect(last()?.status).toBe("Searching community…"); + await waitFor(() => expect(people).toHaveBeenCalledTimes(1)); + await act(async () => + pages.get("La")?.({ people: [lara, larry], hasMore: false }), + ); + expect(labels()).toEqual(["Larkin", "Lara", "Larry Outside"]); + expect(last()?.status).toBeUndefined(); + // A new query keeps still-matching people from the last page while it loads. + view.rerender( + , + ); + await waitFor(() => expect(labels()).toEqual(["Larry Outside"])); + expect(last()?.status).toBe("Searching community…"); + await waitFor(() => expect(people).toHaveBeenCalledTimes(2)); + const newer = { pubkey: "d".repeat(64), name: "Larry Newer" }; + await act(async () => + pages.get("Larr")?.({ people: [newer, larry], hasMore: false }), + ); + // Appended below, never inserted above a visible row. + expect(labels()).toEqual(["Larry Outside", "Larry Newer"]); + // Returning to a settled query is instant and does not read again. + view.rerender( + , + ); + expect(labels()).toEqual(["Larkin", "Lara", "Larry Outside"]); + expect(last()?.status).toBeUndefined(); + await new Promise((resolve) => setTimeout(resolve, 300)); + expect(people).toHaveBeenCalledTimes(2); +}); + +it("waits for a typing pause before reading the directory", async () => { + const t = setup(); + const people = vi.fn(async () => ({ people: [], hasMore: false })); + const session = { + ...t.session, + directMessages: { ...t.session.directMessages, people }, + }; + const props = { + session, + scope: "test", + channelId: "parent", + observation: { revision: 1, text: "@Out", start: 4, end: 4 }, + publish: vi.fn(), + }; + const view = render( + , + ); + for (const query of ["Ou", "Out"]) + view.rerender( + , + ); + await waitFor(() => expect(people).toHaveBeenCalledTimes(1)); + expect(people).toHaveBeenCalledWith("Out", 1, expect.any(AbortSignal)); +}); + it("ignores a late directory result after the query changes and retries the current failure", async () => { const t = setup(); let release = (_result: { @@ -1024,6 +1167,8 @@ it("ignores a late directory result after the query changes and retries the curr const view = render( , ); + // Directory reads wait for a typing pause, so let the old query start first. + await waitFor(() => expect(people).toHaveBeenCalledTimes(1)); view.rerender( , ); @@ -1053,34 +1198,250 @@ it("ignores a late directory result after the query changes and retries the curr ); }); -it("archived identities leave completion and return on unarchive; the viewer is never hidden from themself", async () => { +it("keeps installed rows and fresh authorization through rename, removal, arrivals and archive Retry", async () => { const test = setup(); - const publish = vi.fn(); - const labels = () => - (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( - (item) => item.label, - ); + const a = "a".repeat(64), + b = "b".repeat(64), + c = "c".repeat(64); + let profiles = new Map([ + [a, { name: "Alpha" }], + [b, { name: "Beta" }], + [c, { name: "Aaron Bee" }], + ]); + let list = { + status: "ready" as const, + channels: [{ id: "parent", name: "Parent", members: [a, b] }], + }; + let archive = { status: "ready" as const, archived: [] as string[] }; + const listeners = new Set<() => void>(); + const subscribe = (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }; + const refresh = vi.fn(async () => { + for (const fn of listeners) fn(); + }); + const session = { + ...test.session, + channels: { + ...test.session.channels, + list: () => list, + subscribeList: subscribe, + }, + profiles: { ...test.session.profiles, snapshot: () => profiles, subscribe }, + archives: { + snapshot: () => archive, + subscribe, + state: (key: string) => + archive.archived.includes(key) ? "archived" : "not-archived", + ensure: async () => {}, + refresh, + writable: false, + consent: vi.fn(), + request: vi.fn(), + }, + } as RelaySession; + let result: CompletionResult | undefined; + const publish = (value: CompletionResult) => { + result = value; + return () => {}; + }; const props = { + session, scope: "test", channelId: "parent", observation: { revision: 1, text: "@", start: 1, end: 1 }, query: { start: 0, end: 1, query: "" }, publish, }; - const view = render(); - expect(labels()).toEqual(["Member"]); + const view = render(); + expect(result?.items.map((i) => i.id)).toEqual([a, b]); + const original = result?.items[0]; + act(() => { + profiles = new Map([ + [a, { name: "Zeta" }], + [b, { name: "Beta" }], + [c, { name: "Aaron Bee" }], + ]); + for (const fn of listeners) fn(); + }); + expect(result?.items.map((i) => i.id)).toEqual([a, b]); + expect(result?.items[0]?.label).toBe("Zeta"); + expect(result?.items[0]?.edit).toEqual({ + mention: { pubkey: a, name: "Zeta" }, + }); + act(() => { + list = { + ...list, + channels: [{ id: "parent", name: "Parent", members: [b, c] }], + }; + // Selection sees the new source even before subscriber callbacks/render. + expect(original?.canSelect?.("Enter")).toBe(false); + for (const fn of listeners) fn(); + }); + expect(result?.items.map((i) => i.id)).toEqual([a, b]); + expect(result?.items[0]?.disabled).toBeTruthy(); + act(() => { + archive = { ...archive, archived: [b] }; + for (const fn of listeners) fn(); + }); + expect(result?.items[1]?.disabled).toBe("Archived"); + await act(() => refresh()); + expect(result?.items.map((i) => i.id)).toEqual([a, b]); + view.rerender( + , + ); + expect(result?.items.map((i) => i.id)).toEqual([c]); + view.rerender( + , + ); + expect(result?.items.map((i) => i.id)).toEqual([c]); + act(() => { + list = { + ...list, + channels: [{ id: "parent", name: "Parent", members: [] }], + }; + for (const fn of listeners) fn(); + }); + expect(result?.items.map((i) => i.id)).toEqual([c]); + expect(result?.items[0]?.disabled).toBeTruthy(); + view.unmount(); + test.library.dispose(); +}); + +it("retains button rows while disabling an archived member, then hides it on reopen", async () => { + const test = setup(), + user = userEvent.setup(), + select = vi.fn(() => true); + let archive = { status: "ready" as const, archived: [] as string[] }; + const listeners = new Set<() => void>(); + const session = { + ...test.session, + archives: { + snapshot: () => archive, + state: (key: string) => + archive.archived.includes(key) ? "archived" : "not-archived", + subscribe: (fn: () => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + ensure: async () => {}, + refresh: async () => {}, + writable: false, + consent: vi.fn(), + request: vi.fn(), + }, + } as RelaySession; + render( + , + ); + await user.click(screen.getByRole("button", { name: "Mention a member" })); + const row = screen.getByRole("button", { name: `Member ${"a".repeat(64)}` }); + act(() => { + archive = { ...archive, archived: ["a".repeat(64)] }; + for (const fn of listeners) fn(); + }); + expect(row).toBeDisabled(); + await user.click(row); + expect(select).not.toHaveBeenCalled(); + await user.click(screen.getByRole("button", { name: "Mention a member" })); + await user.click(screen.getByRole("button", { name: "Mention a member" })); + expect( + screen.queryByRole("button", { name: `Member ${"a".repeat(64)}` }), + ).not.toBeInTheDocument(); + test.library.dispose(); +}); + +it("offers usable partial agent choices without waiting for another source", async () => { + const test = setup(); + const snapshot = { + ...test.session.agentChoices.snapshot(), + status: "ready" as const, + pending: true, + complete: false, + identities: [ + { pubkey: "f".repeat(64), name: "Ready Agent", managed: true }, + ], + }; + const session = { + ...test.session, + agentChoices: { ...test.session.agentChoices, snapshot: () => snapshot }, + }; + const view = render( + true} + />, + ); + await userEvent + .setup() + .click(screen.getByRole("button", { name: "Mention a member" })); + expect( + await screen.findByRole("button", { + name: `Ready Agent ${"f".repeat(64)}`, + }), + ).toBeEnabled(); + view.unmount(); + test.library.dispose(); +}); + +it("archived identities leave completion and return on unarchive; the viewer is never hidden from themself", async () => { + const test = setup(); + const publish = vi.fn(); + const rows = () => + (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( + (item) => [item.label, item.disabled], + ); + const props = (query: string) => ({ + scope: "test", + channelId: "parent", + observation: { + revision: 1, + text: `@${query}`, + start: query.length + 1, + end: query.length + 1, + }, + query: { start: 0, end: query.length + 1, query }, + publish, + }); + const view = render( + , + ); + expect(rows()).toEqual([["Member", undefined]]); + // A shown row stays in place but cannot be chosen; the next query drops it. act(() => test.setArchived([test.member])); - expect(labels()).toEqual([]); + expect(rows()).toEqual([["Member", "Archived"]]); + view.rerender(); + expect(rows()).toEqual([]); + // Unarchive returns the row on the next query; installed rows never grow. act(() => test.setArchived([])); - expect(labels()).toEqual(["Member"]); + view.rerender(); + expect(rows()).toEqual([["Member", undefined]]); act(() => test.setArchived([test.member])); view.rerender( , ); - expect(labels()).toEqual(["Member"]); + expect(rows()).toEqual([["Member", undefined]]); test.library.dispose(); }); @@ -1163,7 +1524,7 @@ type RelaySessionPeople = ( query: string, ) => Promise<{ people: { pubkey: string; name: string }[]; hasMore: boolean }>; -it("an exact public key still looks up its author after an empty partial-key search", async () => { +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) => ({ people: query === key ? [{ pubkey: key, name: "Keyholder" }] : [], @@ -1174,12 +1535,12 @@ it("an exact public key still looks up its author after an empty partial-key sea await waitFor(() => expect(people).toHaveBeenCalledTimes(1)); await waitFor(() => expect(last()?.items).toEqual([])); view.rerender(complete(key)); + // The empty partial-key page does not refute the exact-key author lookup. await waitFor(() => expect(people).toHaveBeenLastCalledWith(key, 1, expect.any(AbortSignal)), ); - await waitFor(() => - expect(last()?.items.map((item) => item.label)).toEqual(["Keyholder"]), - ); + // Choices still match names only, never public keys. + await waitFor(() => expect(last()?.items).toEqual([])); }); it("a failed multi-word directory search keeps its error and retry", async () => { @@ -1236,3 +1597,287 @@ it("a fresh search for a refuted name searches again, and non-word text refutes expect(last()?.items.map((item) => item.label)).toEqual(["🐝 Buzz Bot"]), ); }); + +it("uses base matches and visible lexical ties for Fizz in both chooser surfaces", async () => { + const test = setup(); + const rows = [ + { key: "3".repeat(64), name: "Fast Fizz", label: "Fast Fizz" }, + { key: "0".repeat(64), name: "Fizz", label: "baxen’s Fizz · oncp" }, + { key: "4".repeat(64), name: "Fizz", label: "baxen’s Fizz · s03j" }, + { key: "5".repeat(64), name: "Fizz", label: "Kenny Lopez’s Fizz" }, + { key: "b".repeat(64), name: "Fizz", label: "baxen’s Fizz · 4prr" }, + { key: "c".repeat(64), name: "Fizz", label: "baxen’s Fizz · 06pl" }, + ]; + const profiles = new Map( + rows.map((row) => [row.key, { name: row.name, isAgent: true as const }]), + ); + const list = { + status: "ready" as const, + channels: [ + { id: "parent", name: "Parent", members: rows.map((row) => row.key) }, + ], + }; + const names = test.session.names; + if (!names) throw new Error("Missing fixture name service"); + const session = { + ...test.session, + profiles: { ...test.session.profiles, snapshot: () => profiles }, + channels: { ...test.session.channels, list: () => list }, + names: { + ...names, + resolve: (key: string) => rows.find((row) => row.key === key)?.label, + }, + } satisfies RelaySession; + const expected = [rows[5], rows[4], rows[1], rows[2], rows[3], rows[0]].map( + (row) => row?.label, + ); + const publish = vi.fn(); + const view = render( + , + ); + expect( + (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( + (item) => item.label, + ), + ).toEqual(expected); + view.unmount(); + render( + true} + />, + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Mention a member" })); + await user.type(screen.getByRole("searchbox"), "fizz"); + expect( + screen + .getAllByRole("button") + .map((button) => button.getAttribute("aria-label")) + .filter((label) => rows.some((row) => label?.endsWith(row.key))) + .map((label) => label?.slice(0, -65)), + ).toEqual(expected); + test.library.dispose(); +}); + +it("does not complete public keys in either menu", async () => { + const test = setup(); + const key = + "150b20bdf6130418df9239dd1bd082c71612c8d653b47c277200365b9be215dc"; + const honey = "f".repeat(64); + const unnamed = "e".repeat(64); + const profiles = new Map([ + [key, { name: "Bad Janet" }], + [honey, { name: "Honey" }], + ]); + const list = { + status: "ready" as const, + channels: [ + { id: "parent", name: "Parent", members: [key, honey, unnamed] }, + ], + }; + const session = { + ...test.session, + profiles: { ...test.session.profiles, snapshot: () => profiles }, + channels: { ...test.session.channels, list: () => list }, + } satisfies RelaySession; + const publish = vi.fn(); + const props = { + session, + scope: "test", + channelId: "parent", + observation: { revision: 1, text: "@h", start: 2, end: 2 }, + query: { start: 0, end: 2, query: "h" }, + publish, + }; + const view = render(); + const ids = () => + (publish.mock.lastCall?.[0] as CompletionResult | undefined)?.items.map( + (item) => item.id, + ); + expect(ids()).toEqual([honey]); + view.rerender( + , + ); + expect(ids()).toEqual([]); + view.rerender( + , + ); + expect(ids()).toEqual([]); + view.unmount(); + render( + true} + />, + ); + const user = userEvent.setup(); + await user.click(screen.getByRole("button", { name: "Mention a member" })); + await user.type(screen.getByRole("searchbox"), "h"); + expect(screen.queryByRole("button", { name: `Bad Janet ${key}` })).toBeNull(); + expect(screen.getByRole("button", { name: `Honey ${honey}` })).toBeVisible(); + await user.clear(screen.getByRole("searchbox")); + await user.type(screen.getByRole("searchbox"), key); + expect(screen.queryByRole("button", { name: `Bad Janet ${key}` })).toBeNull(); + test.library.dispose(); +}); + +it("keeps still-matching directory people across the inline host's per-keystroke remount", async () => { + // jsdom has no layout; the host positions its popup with these. + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + disconnect() {} + }, + ); + HTMLElement.prototype.scrollIntoView = vi.fn(); + const t = setup(); + const larry = { pubkey: "f".repeat(64), name: "Larry Outside" }; + const lara = { pubkey: "e".repeat(64), name: "Lara" }; + const resolvers = new Map< + string, + (value: { people: (typeof larry)[]; hasMore: boolean }) => void + >(); + const people = vi.fn( + (query: string) => + new Promise<{ people: (typeof larry)[]; hasMore: boolean }>((resolve) => + resolvers.set(query, resolve), + ), + ); + const session = { + ...t.session, + directMessages: { ...t.session.directMessages, people }, + } as unknown as RelaySession; + // The real mention provider behind the real host, which keys each provider + // mount by observation revision and query. + const provider: Contribution = { + id: "typeahead", + key: "buzz.mentions/typeahead", + pluginId: "buzz.mentions", + revision: "1", + title: "Mention", + match: ({ text, start }) => mentionQuery(text, start), + component: MentionCompletion, + }; + const providers = [provider]; + const registry = { snapshot: () => providers, subscribe: () => () => {} }; + // The host reads only its attributes and position; valid() is stubbed. + const input = document.createElement( + "div", + ) as unknown as ComposerInputElement; + document.body.append(input); + const editor = (observation: ComposerObservation) => + ({ + observation, + valid: () => true, + observe: () => {}, + invalidate: () => {}, + keys: { current: undefined }, + composing: { current: false }, + }) as unknown as CompletionEditor; + const host = (revision: number, text: string) => ( + true} + resolved={{ text, recipients: [] }} + session={session} + scope="test" + channelId="parent" + /> + ); + const options = () => + screen.queryAllByRole("option").map((option) => option.textContent); + const view = render(host(1, "@La")); + await waitFor(() => + expect(people).toHaveBeenCalledWith("La", 1, expect.anything()), + ); + await act(async () => + resolvers.get("La")?.({ people: [lara, larry], hasMore: false }), + ); + await waitFor(() => + expect(options()).toEqual([ + expect.stringContaining("Lara"), + expect.stringContaining("Larry Outside"), + ]), + ); + // Each keystroke remounts the provider. The still-matching person stays + // while the new search waits and loads; the non-matching one leaves. + view.rerender(host(2, "@Lar")); + view.rerender(host(3, "@Larr")); + expect(options()).toEqual([expect.stringContaining("Larry Outside")]); + expect(screen.getByRole("status")).toHaveTextContent("Searching community…"); + await waitFor(() => + expect(people).toHaveBeenCalledWith("Larr", 1, expect.anything()), + ); + expect(options()).toEqual([expect.stringContaining("Larry Outside")]); + // A different `@` token is a new chooser lifetime and starts clean. + view.rerender(host(4, "@Larr @Bo")); + expect(options()).toEqual([]); + input.remove(); + vi.unstubAllGlobals(); + delete (HTMLElement.prototype as Partial).scrollIntoView; +}); + +it("the persistent toolbar picker reads an empty search again after close and reopen", async () => { + let published = false; + const people = vi.fn(async () => ({ + people: published ? [{ pubkey: "9".repeat(64), name: "Zed" }] : [], + hasMore: false, + })); + const t = setup(); + const session = { + ...t.session, + directMessages: { ...t.session.directMessages, people }, + }; + render( + true} + />, + ); + const user = userEvent.setup(); + const trigger = screen.getByRole("button", { name: "Mention a member" }); + await user.click(trigger); + await user.type(screen.getByRole("searchbox"), "Zed"); + await waitFor(() => + expect(people).toHaveBeenCalledWith("Zed", 1, expect.any(AbortSignal)), + ); + const calls = people.mock.calls.length; + await user.keyboard("{Escape}"); + await waitFor(() => expect(screen.queryByRole("searchbox")).toBeNull()); + published = true; + // The picker stays mounted while closed, so this is the same hook instance. + await user.click(trigger); + const search = screen.getByRole("searchbox"); + if ((search as HTMLInputElement).value !== "Zed") { + await user.clear(search); + await user.type(search, "Zed"); + } + await waitFor(() => expect(people.mock.calls.length).toBeGreaterThan(calls)); + expect(people).toHaveBeenLastCalledWith("Zed", 1, expect.any(AbortSignal)); + await waitFor(() => + expect( + screen.getByRole("button", { name: `Zed ${"9".repeat(64)}` }), + ).toBeTruthy(), + ); + t.library.dispose(); +}); diff --git a/src/bundled/mentions/use-mention-choices.ts b/src/bundled/mentions/use-mention-choices.ts new file mode 100644 index 000000000..82ae33ef1 --- /dev/null +++ b/src/bundled/mentions/use-mention-choices.ts @@ -0,0 +1,265 @@ +import { useMentionDirectory } from "./useMentionDirectory"; +import { useMentionArchives } from "../../features/messages/use-mention-archives"; +import { + useCallback, + useContext, + useMemo, + useState, + useSyncExternalStore, +} from "react"; +import type { RelaySession } from "../../features/relay/session"; +import { useAgentChoices } from "../../features/agents/use-choices"; +import { useIdentityNames } from "../../features/identity-names/react"; +import { DraftMentionRoster } from "../../features/messages/draft-mention-roster"; +import { SelectedMentionContext } from "../../features/messages/selected-mention-context"; +import { + archivedMention, + mentionCandidates, + mentionHistory, +} from "../../features/messages/mention-candidates"; +import { + exactMention, + rankMentions, + type MentionChoice, +} from "./mention-ranking"; +type Person = ReturnType["people"][number]; +const none: readonly Person[] = []; +/** + * One installed key list per opening/query. Evidence stays live, order does not. + * Local rows install first; directory rows only append after them. + */ +export function useMentionChoices( + session: RelaySession, + channelId: string, + invite: boolean | undefined, + query: string, + /** One chooser opening; see useMentionDirectory. */ + lifetime: string, + open = true, +) { + const roster = useContext(DraftMentionRoster); + const selected = useContext(SelectedMentionContext); + const agents = useAgentChoices(session, !!invite && open); + const profiles = useSyncExternalStore( + session.profiles.subscribe, + session.profiles.snapshot, + session.profiles.snapshot, + ); + const list = useSyncExternalStore( + session.channels.subscribeList, + session.channels.list, + session.channels.list, + ); + const channel = list.channels.find((c) => c.id === channelId); + const directory = useMentionDirectory( + session, + channel, + query, + open && !roster && !invite, + lifetime, + ); + const archives = useMentionArchives(session, open); + const resolve = useIdentityNames(session.names); + const [installed, install] = useState<{ + session: RelaySession; + key: string; + rows: MentionChoice[]; + /** Directory people behind installed outside rows, kept while later pages load. */ + people: readonly Person[]; + }>(); + const key = JSON.stringify([channelId, invite, query, open, !!roster]); + const same = installed?.session === session && installed.key === key; + const carried = same ? installed.people : none; + const people = useMemo(() => { + if (!carried.length) return directory.people; + const known = new Set(directory.people.map((person) => person.pubkey)); + return [ + ...directory.people, + ...carried.filter((person) => !known.has(person.pubkey)), + ]; + }, [directory.people, carried]); + const current = useCallback(() => { + const candidates = mentionCandidates( + session, + channelId, + invite, + roster, + people, + ); + 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 = [ + ...people + .filter((person) => !session.names?.resolve(person.pubkey)) + .map(({ pubkey, name, isAgent }) => ({ + pubkey, + name, + ...(isAgent ? { isAgent } : {}), + })), + ...selected, + ]; + return candidates.map((choice) => ({ + ...choice, + label: + session.names?.resolve( + choice.recipient.pubkey, + choice.recipient.name, + keys, + facts, + ) ?? choice.recipient.name, + })); + }, [session, channelId, invite, roster, selected, people]); + const { candidates, local } = useMemo(() => { + // Source revisions invalidate the projection; selection reads them again synchronously. + void profiles; + void list; + void agents; + void archives; + void resolve; + return { + candidates: current(), + // Keys known without the directory search: members and agent choices. + local: new Set( + mentionCandidates(session, channelId, invite, roster).map( + (c) => c.recipient.pubkey, + ), + ), + }; + }, [ + current, + session, + channelId, + invite, + roster, + profiles, + list, + agents, + archives, + resolve, + ]); + // Only local sources gate the list. Directory results append below it. + const pending = + !roster && + (((list.status === "idle" || list.status === "loading") && + !channel?.members) || + (!!invite && agents.pending && agents.status !== "ready")); + let rows = same ? installed.rows : undefined; + if (!same && installed) install(undefined); + const rank = (choices: MentionChoice[]) => + rankMentions( + choices, + query, + mentionHistory(session, channelId), + (key) => session.presence?.status(key) ?? "unknown", + ); + const outside = open + ? rank(candidates.filter((c) => !local.has(c.recipient.pubkey))) + : []; + if (open && !rows && !pending) { + const ranked = rank( + candidates.filter((c) => local.has(c.recipient.pubkey)), + ).slice(0, 50); + const missingNames = channel?.members?.some((key) => !profiles.has(key)); + if ( + ranked.length || + outside.length || + (!missingNames && + (!invite || !agents.pending) && + list.status === "ready" && + !agents.error && + agents.status !== "error") + ) { + rows = ranked; + install({ session, key, rows, people: none }); + } + } + if (rows && outside.length && rows.length < 50) { + // Append-only: rows already shown never move when directory pages arrive. + const shown = new Set(rows.map((row) => row.recipient.pubkey)); + const added = outside + .filter((c) => !shown.has(c.recipient.pubkey)) + .slice(0, 50 - rows.length); + if (added.length) { + const addedKeys = new Set(added.map((c) => c.recipient.pubkey)); + rows = [...rows, ...added]; + install({ + session, + key, + rows, + people: [ + ...carried, + ...people.filter( + (person) => + addedKeys.has(person.pubkey) && + !carried.some((kept) => kept.pubkey === person.pubkey), + ), + ], + }); + } + } + return useMemo(() => { + const live = new Map(candidates.map((c) => [c.recipient.pubkey, c])); + const choices = (rows ?? []).map((row) => { + const now = live.get(row.recipient.pubkey); + const reason = now + ? row.member && !now.member + ? "Channel membership changed. Reopen to review adding this recipient." + : undefined + : archivedMention(session, row.recipient.pubkey) + ? "Archived" + : "No longer available. Change your search to refresh choices."; + return { + ...(now ?? row), + disabled: reason, + member: now?.member ?? row.member, + }; + }); + return { + choices, + directory: { ...directory, people }, + profiles, + list, + agents, + channel, + roster, + candidates, + pending, + archives, + spaceId: exactMention(candidates, query), + canSelect: (pubkey: string, space = false) => { + const fresh = current(); + return ( + fresh.some( + (c) => + c.recipient.pubkey === pubkey && + (!rows?.find((row) => row.recipient.pubkey === pubkey)?.member || + c.member), + ) && + (!space || exactMention(fresh, query) === pubkey) + ); + }, + truncated: rankMentions(candidates, query).length > 50, + }; + }, [ + session, + rows, + directory, + people, + candidates, + archives, + profiles, + list, + agents, + channel, + roster, + pending, + query, + current, + ]); +} diff --git a/src/bundled/mentions/useMentionDirectory.ts b/src/bundled/mentions/useMentionDirectory.ts index 820eaa43f..73e164fb1 100644 --- a/src/bundled/mentions/useMentionDirectory.ts +++ b/src/bundled/mentions/useMentionDirectory.ts @@ -1,21 +1,69 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import type { RelaySession } from "../../features/relay/session"; import type { ChannelSummary } from "../../features/relay/contracts"; type Person = Awaited< ReturnType >["people"][number]; +type Page = { people: readonly Person[]; more: boolean }; const empty: readonly Person[] = []; +/** Typing pause before an uncached query reaches the network. */ +export const MENTION_DIRECTORY_DELAY_MS = 200; +const CACHE_LIMIT = 100; + +// Successful non-empty first pages, per session (community/viewer) and query. +// Session lifetime bounds staleness; errors are not cached, so Retry reads +// again. Empty results are not cached: `exhausted` owns that evidence, and a +// fresh search for the same query reads again. +const pages = new WeakMap>(); +function cached(session: RelaySession, query: string) { + return pages.get(session)?.get(query); +} +function remember(session: RelaySession, query: string, page: Page) { + let queries = pages.get(session); + if (!queries) { + queries = new Map(); + pages.set(session, queries); + } + queries.delete(query); + queries.set(query, page); + if (queries.size > CACHE_LIMIT) + queries.delete(queries.keys().next().value ?? ""); +} +// 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>(); +const LIFETIME_LIMIT = 20; +function settle(session: RelaySession, lifetime: string, page: Page) { + let lifetimes = settled.get(session); + if (!lifetimes) { + lifetimes = new Map(); + settled.set(session, lifetimes); + } + if (lifetimes.get(lifetime) === page) return; + lifetimes.delete(lifetime); + lifetimes.set(lifetime, page); + if (lifetimes.size > LIFETIME_LIMIT) + lifetimes.delete(lifetimes.keys().next().value ?? ""); +} // Last complete, empty word-prefix search; queries strictly extending it // cannot match anyone. Re-entering the same query searches afresh. const exhausted = new WeakMap(); +const refutedPage: Page = { people: empty, more: false }; -/** Directory pages belong to this menu and community, not the global profile cache. */ +/** + * 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 + * so callers can keep its still-matching people instead of blanking the list. + * `lifetime` names one chooser opening (one picker, or one inline `@` token) + * and must outlive provider remounts within it. + */ export function useMentionDirectory( session: RelaySession, channel: ChannelSummary | undefined, query: string, enabled: boolean, + lifetime: string, ) { const active = enabled && @@ -25,67 +73,86 @@ export function useMentionDirectory( const [attempt, setAttempt] = useState(0); const [state, setState] = useState<{ session: RelaySession; - channelId: string; query: string; - people: readonly Person[]; + attempt: number; + /** The chooser opening this result belongs to. */ + lifetime: string; + /** A settled page that is not cached (an empty result), for this opening only. */ + page?: Page; error?: string; - loading: boolean; - more?: boolean; }>(); - const channelId = channel?.id ?? ""; - const prefix = exhausted.get(session); + const own = + state?.session === session && + state.query === query && + state.attempt === attempt && + 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 searching = - active && - (attempt > 0 || - exactKey || - prefix === undefined || - query.length <= prefix.length || - !query.startsWith(prefix)); + const prefix = exhausted.get(session); + const refuted = + attempt === 0 && + !exactKey && + prefix !== undefined && + query.length > prefix.length && + query.startsWith(prefix); + const hit = active + ? (cached(session, query) ?? + own?.page ?? + (refuted ? refutedPage : undefined)) + : undefined; + const searching = active && !hit; useEffect(() => { if (!searching) return; const controller = new AbortController(); - const current = { session, channelId, query, attempt }; - exhausted.delete(session); - setState({ ...current, people: empty, loading: true }); - void session.directMessages.people(query, 1, controller.signal).then( - ({ people, hasMore }) => { - if (controller.signal.aborted) return; - // The relay ignores non-word text, so it only refutes word prefixes. - if ( - /[\p{L}\p{N}]/u.test(query) && - !exactKey && - !people.length && - !hasMore - ) - exhausted.set(session, query); - setState({ ...current, people, loading: false, more: hasMore }); - }, - () => { - if (!controller.signal.aborted) - setState({ - ...current, - people: empty, - loading: false, - error: "Could not search community people. Retry to refresh.", - }); - }, - ); - return () => controller.abort(); - }, [session, channelId, query, exactKey, searching, attempt]); - const current = - searching && - state?.session === session && - state.channelId === channelId && - state.query === query - ? state - : undefined; - return { - people: current?.people ?? empty, - loading: searching && (!current || current.loading), - error: current?.error, - more: !!current?.more, - retry: useCallback(() => setAttempt((value) => value + 1), []), - }; + const current = { session, query, attempt, lifetime }; + const timer = setTimeout(() => { + exhausted.delete(session); + void session.directMessages.people(query, 1, controller.signal).then( + ({ people, hasMore }) => { + 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 }); + }, + () => { + if (!controller.signal.aborted) + setState({ + ...current, + error: "Could not search community people. Retry to refresh.", + }); + }, + ); + }, MENTION_DIRECTORY_DELAY_MS); + return () => { + clearTimeout(timer); + controller.abort(); + }; + }, [session, query, searching, exactKey, 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); + }, [session, query]); + return useMemo( + () => ({ + /** The current query's page, or the last settled page while it loads. */ + people: (active && shown?.people) || empty, + loading: active && !hit && !error, + error, + more: !!hit?.more, + retry, + }), + [active, shown, hit, error, retry], + ); } diff --git a/src/features/conversation/Completions.module.css b/src/features/conversation/Completions.module.css index e596d350a..ce129cefa 100644 --- a/src/features/conversation/Completions.module.css +++ b/src/features/conversation/Completions.module.css @@ -136,3 +136,7 @@ font-family: var(--font-mono); color: var(--text-metadata); } +.option[aria-disabled="true"] { + color: var(--text-subtle); + cursor: not-allowed; +} diff --git a/src/features/conversation/ComposerCompletions.tsx b/src/features/conversation/ComposerCompletions.tsx index 65dc36472..e05823ebc 100644 --- a/src/features/conversation/ComposerCompletions.tsx +++ b/src/features/conversation/ComposerCompletions.tsx @@ -48,6 +48,7 @@ export function ComposerCompletions({ edit: CompletionEdit, query: CompletionQuery, observation: ComposerObservation, + key?: string, ): boolean; }) { const providers = useSyncExternalStore( @@ -110,6 +111,7 @@ function OwnedCompletion({ edit: CompletionEdit, query: CompletionQuery, observation: ComposerObservation, + key?: string, ): boolean; }) { const id = useId(); @@ -177,15 +179,20 @@ function OwnedCompletion({ : items.findIndex((item) => item.id === selected); const selectedIndex = index < 0 ? 0 : index; const status = result?.status; - function accept(index: number) { + function accept(index: number, key = "click") { if (!active() || latest.current !== result) return false; if (index === items.length && result?.retry) { result.retry(); return true; } const item = items[index]; - if (!item) return false; - const accepted = current.current.replace(item.edit, query, observation); + if (!item || item.disabled || item.canSelect?.(key) === false) return false; + const accepted = current.current.replace( + item.edit, + query, + observation, + key, + ); if (accepted) current.current.editor.invalidate(); return accepted; } @@ -226,6 +233,15 @@ function OwnedCompletion({ } return false; } + if (event.key === " " && result?.spaceId) { + const exact = items.findIndex((item) => item.id === result.spaceId); + if (accept(exact, " ")) { + event.preventDefault(); + event.stopPropagation(); + return true; + } + return false; + } if ((event.key === "ArrowDown" || event.key === "ArrowUp") && count) { event.preventDefault(); event.stopPropagation(); @@ -238,7 +254,7 @@ function OwnedCompletion({ if ((event.key === "Enter" || event.key === "Tab") && count) { event.preventDefault(); event.stopPropagation(); - accept(selectedIndex); + accept(selectedIndex, event.key); return true; } return false; @@ -293,6 +309,7 @@ function OwnedCompletion({ role="option" tabIndex={-1} aria-selected={i === selectedIndex} + aria-disabled={!!item.disabled} aria-label={ compact && item.detail ? `${item.label} ${item.detail}` diff --git a/src/features/conversation/completion.ts b/src/features/conversation/completion.ts index eb21d961c..b8fdce861 100644 --- a/src/features/conversation/completion.ts +++ b/src/features/conversation/completion.ts @@ -99,6 +99,12 @@ export function completionResult(result: CompletionResult): CompletionResult { label: item.label, ...(typeof item.detail === "string" ? { detail: item.detail } : {}), preview: item.preview, + ...(typeof item.disabled === "string" + ? { disabled: item.disabled } + : {}), + ...(typeof item.canSelect === "function" + ? { canSelect: item.canSelect } + : {}), edit: Object.freeze( "mention" in edit ? { mention: Object.freeze({ ...edit.mention }) } @@ -109,6 +115,9 @@ export function completionResult(result: CompletionResult): CompletionResult { } return Object.freeze({ items: Object.freeze(items), + ...(typeof result.spaceId === "string" && ids.has(result.spaceId) + ? { spaceId: result.spaceId } + : {}), ...(typeof result.status === "string" ? { status: result.status } : {}), ...(typeof result.retry === "function" ? { retry: result.retry } : {}), }); diff --git a/src/features/conversation/contracts.ts b/src/features/conversation/contracts.ts index 7fb680feb..1cec05b12 100644 --- a/src/features/conversation/contracts.ts +++ b/src/features/conversation/contracts.ts @@ -126,9 +126,15 @@ export type CompletionSuggestion = Readonly<{ /** Decorative presentation only; the host owns option semantics and interaction. */ preview?: import("react").ReactNode; edit: CompletionEdit; + /** Keep an installed identity in place after eligibility is revoked. */ + disabled?: string | undefined; + /** Final synchronous evidence check; false never falls through to sending. */ + canSelect?: ((key: string) => boolean) | undefined; }>; export type CompletionResult = Readonly<{ items: readonly CompletionSuggestion[]; + /** Provider-verified unique exact match across its uncapped candidate set. */ + spaceId?: string | undefined; status?: string; /** Optional explicit recovery. A new query or disposal revokes this action. */ retry?: () => void; diff --git a/src/features/messages/MessageComposer.test.tsx b/src/features/messages/MessageComposer.test.tsx index e11043535..b8504f7ec 100644 --- a/src/features/messages/MessageComposer.test.tsx +++ b/src/features/messages/MessageComposer.test.tsx @@ -2647,3 +2647,77 @@ it.each(["retry", "unmount", "retarget", "disabled"])( expect(h.messages.send).toHaveBeenCalledTimes(outcome === "retry" ? 1 : 0); }, ); + +it("disabled completion keeps the highlighted key and consumes Enter without sending", async () => { + const h = mount(); + h.input().focus(); + h.fill("!Honey"); + const publish = h.completionRequests.at(-1); + if (!publish) throw new Error("No completion request"); + act(() => { + publish({ + items: [ + { id: first.pubkey, label: "First Honey", edit: { mention: first } }, + { id: second.pubkey, label: "Second Honey", edit: { mention: second } }, + ], + }); + }); + fireEvent.keyDown(h.input(), { key: "ArrowDown" }); + act(() => { + publish({ + items: [ + { id: first.pubkey, label: "First Honey", edit: { mention: first } }, + { + id: second.pubkey, + label: "Second Honey", + edit: { mention: second }, + disabled: "Archived", + }, + ], + }); + }); + expect(screen.getByRole("option", { name: "Second Honey" })).toHaveAttribute( + "aria-selected", + "true", + ); + expect(screen.getByRole("option", { name: "Second Honey" })).toHaveAttribute( + "aria-disabled", + "true", + ); + fireEvent.keyDown(h.input(), { key: "Enter" }); + expect(h.input()).toHaveValue("!Honey"); + expect(h.messages.send).not.toHaveBeenCalled(); +}); + +it("rejects a known archived recipient at send entry without clearing the draft", () => { + const h = mount(); + let archived = false; + const snapshot = { status: "ready" as const, archived: [] as string[] }; + h.retarget({ + session: { + ...h.session, + archives: { + snapshot: () => snapshot, + subscribe: () => () => {}, + state: () => (archived ? "archived" : "not-archived"), + ensure: async () => {}, + refresh: async () => {}, + writable: false, + consent: vi.fn(), + request: vi.fn(), + }, + }, + }); + act(() => { + expect(h.commands().insertMention(first)).toBe(true); + }); + archived = true; + h.submit(); + expect(h.messages.send).not.toHaveBeenCalled(); + expect(h.input()).toHaveValue(`@${first.name} `); + expect( + screen.getByText( + "A selected recipient is archived. Remove it before sending.", + ), + ).toBeVisible(); +}); diff --git a/src/features/messages/MessageComposer.tsx b/src/features/messages/MessageComposer.tsx index 4ebbf51e9..a25b0ffd7 100644 --- a/src/features/messages/MessageComposer.tsx +++ b/src/features/messages/MessageComposer.tsx @@ -1,5 +1,16 @@ import { animate, useReducedMotion } from "motion/react"; import { ToastNotice } from "../../shared/design-system/ui/Toast"; +import { SelectedMentionContext } from "./selected-mention-context"; +import { DraftMentionRoster } from "./draft-mention-roster"; +import { + archivedMention, + mentionCandidates, + rememberMention, +} from "./mention-candidates"; +import { + readComposerSnapshot, + composerMarkdownContext, +} from "./composer-document"; import { useMessageEdit, lastEditableMessage } from "./useMessageEdit"; import { npubEncode } from "nostr-tools/nip19"; import type { ChannelMessage } from "../relay/contracts"; @@ -23,6 +34,7 @@ import { import { ComposerAttachments } from "./ComposerAttachments"; import { useAttachmentDraft } from "./attachment-draft"; import { + useContext, useEffect, useCallback, useId, @@ -194,6 +206,7 @@ function Composer({ const parentChannelId = list.channels.find( (item) => item.id === channelId, )?.parentChannelId; + const mentionRoster = useContext(DraftMentionRoster); const agentChoices = inviteAgents || !!sessionConversation; const [value, updateDraft] = useState(() => mentionDraft( @@ -382,6 +395,17 @@ function Composer({ text = `nostr:${npubEncode(recipient.pubkey)} `; recipient = undefined; } + if ( + recipient && + !mentionCandidates(session, channelId, agentChoices, mentionRoster, [ + recipient, + ]).some((c) => c.recipient.pubkey === recipient.pubkey) + ) { + setError( + "This recipient is no longer available. Remove it or refresh choices.", + ); + return false; + } if (recipient && valueRef.current.recipients.length >= 32) { setError("Choose at most 32 recipients"); return false; @@ -391,6 +415,7 @@ function Composer({ setError("Message is too long to insert text"); return false; } + if (recipient) rememberMention(session, channelId, recipient.pubkey); setError(undefined); return true; } @@ -409,12 +434,23 @@ function Composer({ edit: CompletionEdit, query: CompletionQuery, observation: ComposerObservation, + key?: string, ) { if ( !completion.valid(observation) || valueRef.current.text !== observation.text ) return false; + if (key === " ") { + const doc = readComposerSnapshot(valueRef.current.document); + if ( + doc && + composerMarkdownContext(doc).protected.some( + (r) => query.start < r.end && query.end > r.start, + ) + ) + return false; + } if ("mention" in edit && edit.mention) return insert(`@${edit.mention.name} `, edit.mention, query); return ( @@ -490,6 +526,10 @@ function Composer({ const captured = valueRef.current; const capturedAttachments = attachments.store.snapshot(); try { + if (captured.recipients.some((p) => archivedMention(session, p.pubkey))) + throw new Error( + "A selected recipient is archived. Remove it before sending.", + ); if (submission) { submission.submit(captured); return; @@ -576,7 +616,16 @@ function Composer({ ); const next = followupDraft( rememberAgentsPreference() - ? captured.recipients.filter((item) => agents.has(item.pubkey)) + ? captured.recipients.filter( + (item) => + agents.has(item.pubkey) && + mentionCandidates( + session, + channelId, + agentChoices, + mentionRoster, + ).some((c) => c.recipient.pubkey === item.pubkey), + ) : [], ); const changed = saveDraft(next); @@ -667,7 +716,7 @@ function Composer({ ); return ( - <> + {accessories} {nonmembers.dialog}
setLinkEdit(null)} /> )} - + ); } diff --git a/src/features/messages/RichComposerInput.tsx b/src/features/messages/RichComposerInput.tsx index 12500eb62..d44919b3a 100644 --- a/src/features/messages/RichComposerInput.tsx +++ b/src/features/messages/RichComposerInput.tsx @@ -1,4 +1,7 @@ -import { availableMentionAgents } from "../agents/mention-choices"; +import { useMentionArchives } from "./use-mention-archives"; +import { useContext } from "react"; +import { DraftMentionRoster } from "./draft-mention-roster"; +import { mentionCandidates } from "./mention-candidates"; import { useAgentChoices } from "../agents/use-choices"; import { useLayoutEffect, useMemo, useRef } from "react"; import { useIdentityNames } from "../identity-names/react"; @@ -38,6 +41,8 @@ export function RichComposerInput({ inviteAgents?: boolean; }) { const directory = useReferenceDirectory(session); + useMentionArchives(session); + const roster = useContext(DraftMentionRoster); const profiles = new Map(directory.profiles); for (const recipient of draft.recipients) profiles.set(recipient.pubkey, { @@ -45,22 +50,13 @@ export function RichComposerInput({ name: recipient.name, }); const resolveName = useIdentityNames(session.names); - const agents = useAgentChoices(session, inviteAgents); - const channel = directory.channels.find( - (channel) => channel.id === channelId, - ); - const available = availableMentionAgents( - channel, - agents.identities, - inviteAgents, - session.outbox?.supports(9000), - ); + useAgentChoices(session, inviteAgents); const candidates = [ ...new Set([ - ...(channel?.members ?? []), - ...available.map((agent) => agent.pubkey), - ...(inviteAgents ? agents.identities.map((agent) => agent.pubkey) : []), - ...draft.recipients.map((recipient) => recipient.pubkey), + ...mentionCandidates(session, channelId, inviteAgents, roster).map( + (c) => c.recipient.pubkey, + ), + ...draft.recipients.map((p) => p.pubkey), ]), ]; const displayFacts = draft.recipients diff --git a/src/features/messages/mention-candidates.ts b/src/features/messages/mention-candidates.ts new file mode 100644 index 000000000..d2a2af1e6 --- /dev/null +++ b/src/features/messages/mention-candidates.ts @@ -0,0 +1,120 @@ +import type { RelaySession } from "../relay/session"; +import type { MentionRecipient } from "./mention-draft"; +import { availableMentionAgents } from "../agents/mention-choices"; +import { knownAgentPubkeys } from "../agents/known"; + +/** + * Base Buzz discovery rule: known-archived identities leave forward-looking + * choices, fail-open while archive state is unknown, and never hide the viewer + * from themself (NIP-IA archival stays visible to its subject). + */ +export function archivedMention(session: RelaySession, pubkey: string) { + return ( + pubkey !== session.viewer && session.archives?.state(pubkey) === "archived" + ); +} + +/** Recipient eligibility, shared by menus, draft naming and insertion. No reads or writes. */ +export function mentionCandidates( + session: RelaySession, + channelId: string, + inviteAgents = false, + roster?: readonly MentionRecipient[], + directory: readonly (MentionRecipient & { isAgent?: true })[] = [], +) { + const channel = session.channels + .list() + .channels.find((c) => c.id === channelId); + const members = roster?.map((p) => p.pubkey) ?? channel?.members ?? []; + const agents = session.agentChoices.snapshot().identities; + const profiles = session.profiles.snapshot(); + const known = knownAgentPubkeys(profiles, { + definitions: [], + identities: agents, + }); + const choices = new Map(); + if (!channel?.archived && !channel?.readOnly) { + for (const person of roster ?? + (inviteAgents && channel?.channelType !== "dm" + ? agents + : availableMentionAgents( + channel, + agents, + false, + session.outbox?.supports(9000), + ))) + choices.set(person.pubkey, { pubkey: person.pubkey, name: person.name }); + if ( + !roster && + !inviteAgents && + (channel?.channelType === "stream" || channel?.channelType === "forum") + ) + for (const person of directory) choices.set(person.pubkey, person); + for (const pubkey of members) + choices.set(pubkey, { + pubkey, + name: + profiles.get(pubkey)?.name || + choices.get(pubkey)?.name || + pubkey.slice(0, 12), + }); + } + return [...choices.values()] + .filter( + (p) => + /^[0-9a-f]{64}$/.test(p.pubkey) && !archivedMention(session, p.pubkey), + ) + .map((recipient) => ({ + recipient, + member: members.includes(recipient.pubkey), + agent: + known.has(recipient.pubkey) || + directory.some((p) => p.pubkey === recipient.pubkey && p.isAgent), + owned: + !!session.viewer && + profiles.get(recipient.pubkey)?.ownerPubkey === session.viewer, + managed: agents.some((a) => a.pubkey === recipient.pubkey && a.managed), + aliases: [ + ...new Set( + [ + profiles.get(recipient.pubkey)?.name || + directory.find((person) => person.pubkey === recipient.pubkey) + ?.name || + roster?.find((person) => person.pubkey === recipient.pubkey) + ?.name, + ...agents + .filter((a) => a.pubkey === recipient.pubkey) + .map((a) => a.name), + ].filter((name): name is string => !!name), + ), + ], + })); +} + +/** Session lifetime isolates community/viewer; bounded per-destination explicit choices. */ +const histories = new WeakMap>>(); +export function mentionHistory(session: RelaySession, channelId: string) { + return histories.get(session)?.get(channelId); +} +export function rememberMention( + session: RelaySession, + channelId: string, + pubkey: string, +) { + let destinations = histories.get(session); + if (!destinations) { + destinations = new Map(); + histories.set(session, destinations); + } + let history = destinations.get(channelId); + if (!history) { + history = new Map(); + destinations.set(channelId, history); + } + const next = Math.max(0, ...history.values()) + 1; + history.delete(pubkey); + history.set(pubkey, next); + if (history.size > 100) history.delete(history.keys().next().value ?? ""); + if (destinations.size > 100) + destinations.delete(destinations.keys().next().value ?? ""); +} diff --git a/src/features/messages/selected-mention-context.ts b/src/features/messages/selected-mention-context.ts new file mode 100644 index 000000000..672f496ce --- /dev/null +++ b/src/features/messages/selected-mention-context.ts @@ -0,0 +1,6 @@ +import { createContext } from "react"; +import type { MentionRecipient } from "./mention-draft"; +/** Selected keys participate in naming even if no longer eligible for another selection. */ +export const SelectedMentionContext = createContext< + readonly MentionRecipient[] +>([]); diff --git a/src/features/messages/use-mention-archives.ts b/src/features/messages/use-mention-archives.ts new file mode 100644 index 000000000..5f73650e4 --- /dev/null +++ b/src/features/messages/use-mention-archives.ts @@ -0,0 +1,17 @@ +import { useEffect, useSyncExternalStore } from "react"; +import type { RelaySession } from "../relay/session"; +const noop = () => () => {}; +const unavailable = { status: "unavailable", archived: [] } as const; +const empty = () => unavailable; +/** Observe existing archive evidence; only an open chooser demands its lazy read. */ +export function useMentionArchives(session: RelaySession, demand = false) { + const snapshot = useSyncExternalStore( + session.archives?.subscribe ?? noop, + session.archives?.snapshot ?? empty, + empty, + ); + useEffect(() => { + if (demand) void session.archives?.ensure(); + }, [session, demand]); + return snapshot; +} diff --git a/src/features/relay/use-archived.ts b/src/features/relay/use-archived.ts deleted file mode 100644 index d65d6cd5e..000000000 --- a/src/features/relay/use-archived.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { useEffect, useMemo, useSyncExternalStore } from "react"; -import type { RelaySession } from "./session"; - -/** Base Buzz discovery predicate: archived identities leave forward-looking pickers. - * Fail-open while the snapshot is unknown, and never hides the viewer from - * themself (NIP-IA archival must stay visible to its subject). */ -export function useArchivedPredicate( - session: RelaySession, -): (pubkey: string) => boolean { - const snapshot = useSyncExternalStore( - session.archives.subscribe, - session.archives.snapshot, - session.archives.snapshot, - ); - useEffect(() => { - if (snapshot.status === "idle") void session.archives.ensure(); - }, [session, snapshot.status]); - return useMemo(() => { - const archived = new Set( - snapshot.status === "ready" ? snapshot.archived : [], - ); - return (pubkey) => pubkey !== session.viewer && archived.has(pubkey); - }, [session, snapshot]); -} diff --git a/tests/browser/completion-work.spec.mjs b/tests/browser/completion-work.spec.mjs index 6a2185afe..62eb08020 100644 --- a/tests/browser/completion-work.spec.mjs +++ b/tests/browser/completion-work.spec.mjs @@ -217,8 +217,18 @@ test("completed, dismissed and refuted mention searches stay closed while fresh await input.press("End"); await input.pressSequentially(" @Hon"); await expect(options).toHaveCount(2); + // The fresh trigger gets "Hon" directory evidence after the typing pause: + // a new read, or the session's settled page for the same query. + await expect + .poll(async () => { + const all = await searches(); + return ( + all.slice(before.length).at(-1) ?? + all.findLast((search) => search === "Hon") + ); + }) + .toBe("Hon"); const added = (await searches()).slice(before.length); - expect(added.at(-1)).toBe("Hon"); expect(added.filter((search) => !"Hon".startsWith(search))).toEqual([]); await input.press("Escape"); await expect(listbox).toHaveCount(0); @@ -262,9 +272,10 @@ test("completed, dismissed and refuted mention searches stay closed while fresh await input.fill(""); await page.evaluate(() => window.mentionFixture.holdSearches()); try { - await input.pressSequentially("@Hon"); + // An uncached query, so a directory read is in flight when dismissed. + await input.pressSequentially("@Hone"); await expect(options).toHaveCount(2); - await expect.poll(held).toContain("Hon"); + await expect.poll(held).toContain("Hone"); await input.press("Escape"); await expect(listbox).toHaveCount(0); } finally { diff --git a/tests/browser/mention-rules.spec.mjs b/tests/browser/mention-rules.spec.mjs new file mode 100644 index 000000000..5b24c0b34 --- /dev/null +++ b/tests/browser/mention-rules.spec.mjs @@ -0,0 +1,121 @@ +import { test, expect } from "./source-fixture.mjs"; + +// Real contenteditable key/default-action boundary; unit tests own the ranking matrix. +test("Space commits a unique exact identity but leaves namesakes as prose", async ({ + page, +}) => { + await page.goto("/tests/fixtures/mentions.html?delayed-profiles"); + await page.evaluate(() => window.mentionFixture.releaseProfiles()); + const input = page.getByRole("textbox", { name: "Message #General" }); + // Exact-name Space must not create intent inside an actual rich-editor code span. + await input.fill("`@Mary Jane`"); + await input.evaluate((el) => el.setSelectionRange(11, 11)); + await input.press("Space"); + await expect(input).toHaveJSProperty("value", "`@Mary Jane `"); + await expect(input.locator(".inline-chip")).toHaveCount(0); + await input.fill("@Mary Jane"); + await expect(page.getByRole("option", { name: /^Mary Jane / })).toBeVisible(); + await input.press("Space"); + await expect(input.locator(".inline-chip")).toHaveText("@Mary Jane"); + await input.pressSequentially("hello"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await expect + .poll(() => + page.evaluate( + () => + window.mentionFixture.publications.filter((e) => e.kind === 9).length, + ), + ) + .toBe(1); + const sent = await page.evaluate(() => ({ + event: window.mentionFixture.publications.find((e) => e.kind === 9), + key: window.mentionFixture.first, + })); + expect(sent.event.tags).toContainEqual(["p", sent.key]); + await page.goto("/tests/fixtures/mentions.html"); + await input.fill("@Honey"); + await expect(page.getByRole("option", { name: /^Honey / })).toHaveCount(2); + await input.press("Space"); + await expect(input).toHaveJSProperty("value", "@Honey "); + await expect(input.locator(".inline-chip")).toHaveCount(0); +}); + +test("an open list preserves keys and highlight when membership is revoked", async ({ + page, +}) => { + await page.goto("/tests/fixtures/mentions.html"); + const input = page.getByRole("textbox", { name: "Message #General" }); + await input.fill("@Honey"); + const rows = page.getByRole("option"); + await expect(rows).toHaveCount(2); + const rowKeys = () => + rows.evaluateAll((nodes) => + nodes.map((n) => n.querySelector("small")?.textContent), + ); + const before = await rowKeys(); + const revokedKey = await page.evaluate(() => window.mentionFixture.first); + const removed = page.getByRole("option", { name: new RegExp(revokedKey) }); + if ((await removed.getAttribute("aria-selected")) !== "true") + await input.press("ArrowDown"); + await expect(removed).toHaveAttribute("aria-selected", "true"); + const selectedId = await input.getAttribute("aria-activedescendant"); + await page.evaluate(() => window.mentionFixture.removeFirst()); + await expect( + page.getByRole("option", { name: /No longer available/ }), + ).toHaveAttribute("aria-disabled", "true"); + const archived = page.getByRole("option", { name: /No longer available/ }); + await expect(archived).toHaveAttribute("aria-selected", "true"); + await expect(input).toHaveAttribute("aria-activedescendant", selectedId); + const position = before.indexOf(revokedKey); + await expect(rows.nth(position)).toHaveAttribute("aria-disabled", "true"); + await input.press("Enter"); + await expect(input).toHaveJSProperty("value", "@Honey"); + await expect(input.locator(".inline-chip")).toHaveCount(0); + expect( + await page.evaluate(() => window.mentionFixture.publications.length), + ).toBe(0); + expect(before).toHaveLength(2); +}); + +// The real editor, dialog focus contract, directory and signed writer must agree. +test("outside people survive Close and send reference-only with Send anyway", async ({ + page, +}) => { + await page.goto("/tests/fixtures/mentions.html?nonmember-admission"); + const input = page.getByRole("textbox", { name: "Message #General" }); + await input.fill("@Outside"); + await page.getByRole("option", { name: /^Outside Person / }).click(); + await expect(input.locator(".inline-chip")).toHaveText("@Outside Person"); + await input.pressSequentially("hello"); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + const dialog = page.getByRole("dialog", { + name: "Mention people outside this channel?", + }); + await expect(dialog).toBeVisible(); + // block/buzz parity: this fixture cannot add members, so no Invite or Cancel. + await expect(dialog.getByRole("button", { name: "Invite" })).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Cancel" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Close", exact: true }).click(); + await expect(dialog).not.toBeVisible(); + await expect(input).toBeFocused(); + await expect(input).toHaveJSProperty("value", "@Outside Person hello"); + expect(await page.evaluate(() => window.mentionFixture.publications)).toEqual( + [], + ); + await page.getByRole("button", { name: "Send message", exact: true }).click(); + await dialog + .getByRole("button", { name: "Send anyway", exact: true }) + .click(); + await expect + .poll(() => page.evaluate(() => window.mentionFixture.publications.length)) + .toBe(1); + const { event, key } = await page.evaluate(() => ({ + event: window.mentionFixture.publications[0], + key: window.mentionFixture.outsider, + })); + expect(event.kind).toBe(9); + expect(event.tags).toContainEqual(["mention", key]); + expect(event.tags.filter((tag) => tag[0] === "p")).toEqual([]); + expect(event.content).toBe("@Outside Person hello"); + await expect(input).toHaveJSProperty("value", ""); +}); diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index b169f495e..c4a01c5ec 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -18,7 +18,12 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta envFile: false, plugins: [react()], logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, + server: { + host: "127.0.0.1", + port: 0, + // Match the app server: native builds must not reload an editing fixture. + watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, + }, }); const errors = []; page.on("pageerror", (error) => errors.push(String(error))); @@ -60,7 +65,8 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta name: "Mention a member or agent", }); const search = picker.getByRole("searchbox"); - await search.fill(key); + // Both fixture keys are Honey namesakes; public keys are not search terms. + await search.fill("Honey"); await expect( picker.getByRole("button", { name: new RegExp(key) }), ).toBeVisible(); @@ -476,7 +482,12 @@ test("selected mentions inside code remain visible through draft restore and cha envFile: false, plugins: [react()], logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, + server: { + host: "127.0.0.1", + port: 0, + // Match the app server: native builds must not reload an editing fixture. + watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, + }, }); try { await server.listen(); @@ -567,7 +578,12 @@ test("namesake recipient qualifiers remain visible on touch after live name chan envFile: false, plugins: [react()], logLevel: "error", - server: { host: "127.0.0.1", port: 0 }, + server: { + host: "127.0.0.1", + port: 0, + // Match the app server: native builds must not reload an editing fixture. + watch: { ignored: ["**/src-tauri/**", "**/target/**"] }, + }, }); try { await server.listen(); diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index dfe7debc4..daa29a7cf 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -19,6 +19,7 @@ import { roster, profile, message, + signed, } from "../../src/features/relay/testing"; import { matchesEvent } from "../../src/features/relay/projection"; import type { RelayEvent } from "../../src/features/relay/events"; @@ -26,7 +27,8 @@ import type { RelayEvent } from "../../src/features/relay/events"; const viewer = keypair(), relay = keypair(), first = keypair(), - second = keypair(); + second = keypair(), + outsider = keypair(); let members = [viewer.pubkey, first.pubkey, second.pubkey]; let time = 1700000000; const publications: RelayEvent[] = []; @@ -36,6 +38,9 @@ let libraryReads = 0; const reads: (readonly number[])[] = []; let pendingReads = 0; let libraryIncludesFirst = false; +const admission = new URLSearchParams(location.search).has( + "nonmember-admission", +); const naming = new URLSearchParams(location.search).has("identity-names"); let colliding = false; const delayed = new URLSearchParams(location.search).has("delayed-profiles"); @@ -108,13 +113,27 @@ const owner = createRelaySession( } const events = [ roster(relay, "c", members, time), - metadata( - relay, - "c", - "General", - undefined, - stream ? [["t", "stream"]] : [], - ), + admission + ? signed(relay, { + kind: 39000, + content: JSON.stringify({ + name: "General", + channel_type: "stream", + }), + created_at: time, + tags: [ + ["d", "c"], + ["name", "General"], + ["t", "stream"], + ], + }) + : metadata( + relay, + "c", + "General", + undefined, + stream ? [["t", "stream"]] : [], + ), roster(relay, "other", [viewer.pubkey], time), metadata(relay, "other", "Other"), profile(viewer, { name: "Viewer" }), @@ -127,18 +146,22 @@ const owner = createRelaySession( is_agent: true, picture: "https://avatars.test/app-icon.png", }), + ...(admission ? [profile(outsider, { name: "Outside Person" })] : []), ...publications, ]; return events.filter((event) => - filters.some((filter) => - filter.search === undefined - ? matchesEvent(event, filter) - : // Name-prefix directory search, like the relay's prefix mode. - event.kind === 0 && - String(JSON.parse(event.content).name ?? "") - .toLowerCase() - .startsWith(filter.search.toLowerCase()), - ), + filters.some((filter) => { + const { search, search_mode: _mode, ...ordinary } = filter; + // Name-prefix directory search, like the relay's prefix mode. + return ( + matchesEvent(event, ordinary) && + (search === undefined || + (event.kind === 0 && + String(JSON.parse(event.content).name ?? "") + .toLowerCase() + .startsWith(search.toLowerCase()))) + ); + }), ); } finally { pendingReads--; @@ -220,6 +243,7 @@ Object.assign(window, { await owner.session.agentLibrary.refresh(); }, qualifier: (key: string) => names?.lookup(key)?.qualifier, + outsider: outsider.pubkey, first: first.pubkey, second: second.pubkey, publications,