diff --git a/src/bundled/channels/ChannelMembersDialog.test.tsx b/src/bundled/channels/ChannelMembersDialog.test.tsx index 12e62cc372..da97fcd51b 100644 --- a/src/bundled/channels/ChannelMembersDialog.test.tsx +++ b/src/bundled/channels/ChannelMembersDialog.test.tsx @@ -1,6 +1,8 @@ // @vitest-environment jsdom import { SOLO_LABEL } from "../../features/solo/labels"; import type { Agents } from "../../features/agents/service"; +import type { CommunityReader } from "../../features/communities/service"; +import { emptySync } from "../../features/communities/known-communities"; import "@testing-library/jest-dom/vitest"; import { act, @@ -18,7 +20,20 @@ import userEvent from "@testing-library/user-event"; import { beforeEach, afterEach, expect, it, vi } from "vitest"; import { useEffect } from "react"; import { createRelaySession } from "../../features/relay/session"; -import { keypair, profile, roster, signed } from "../../features/relay/testing"; +import { + keypair, + metadata, + profile, + roster, + signed, +} from "../../features/relay/testing"; +import { personKey } from "../../features/search/people-rank"; +import { + pickerText, + readSearchUsage, + recordChoice, + recordVisit, +} from "../../features/search/usage"; import { matchesEvent } from "../../features/relay/projection"; import type { RelayEvent } from "../../features/relay/events"; import { PublishRejected } from "../../features/relay/outbox"; @@ -51,6 +66,7 @@ function ownedTask( } // jsdom lacks scrollIntoView; the search highlight keeps its row in view. beforeEach(() => { + localStorage.clear(); Object.defineProperty(HTMLElement.prototype, "scrollIntoView", { configurable: true, value: vi.fn(), @@ -78,7 +94,11 @@ async function setup( onOpenLink(target: string, returnFocus?: HTMLElement): boolean; }, agents?: Agents, - options: { archivedAgent?: boolean; channelCreation?: boolean } = {}, + options: { + archivedAgent?: boolean; + channelCreation?: boolean; + communities?: CommunityReader; + } = {}, ) { const viewer = keypair(), relay = keypair(), @@ -194,6 +214,7 @@ async function setup( @@ -214,6 +235,7 @@ async function setup( query, readAgentLibrary, session: owner.session, + relay, person, viewer, delayRoster: () => { @@ -257,6 +279,618 @@ async function setup( }, }; } +it("offers your agents before typing and adds one without a directory search", async () => { + const t = await setup(); + const yours = await screen.findByRole("region", { + name: "Your agents", + }); + const add = within(yours).getByRole("button", { name: /^Add Fixture agent/ }); + expect(screen.getByRole("searchbox")).toHaveValue(""); + await t.user.click(add); + await screen.findByText("Fixture agent is in the channel."); + expect( + screen.queryByRole("button", { name: /^Add Fixture agent/ }), + ).toBeNull(); + expect( + t.query.mock.calls + .flatMap(([filters]) => filters) + .filter((filter) => filter.search), + ).toEqual([]); +}); + +it("explains imported agents with the current community's badge and nested popover", async () => { + const snapshot: ReturnType = { + status: "ready", + relayAvailable: true, + profile: { name: "Carl", picture: "" }, + memberships: [ + { + id: "https://relay.example.test", + name: "Fixture community", + publishedIcon: "https://images.example/community.png", + }, + ], + selected: "https://other.example.test", + sync: emptySync(), + }; + const t = await setup("stream", false, false, undefined, undefined, { + communities: { snapshot: () => snapshot, subscribe: () => () => {} }, + }); + const group = await screen.findByRole("region", { name: "Your agents" }); + const badge = within(group).getByRole("button", { + name: "Agents scope: Fixture community", + }); + expect(badge.querySelector("img")).toHaveAttribute( + "src", + "https://images.example/community.png", + ); + expect(screen.queryByRole("dialog", { name: "Agents scope" })).toBeNull(); + await t.user.click(badge); + expect( + await screen.findByRole("dialog", { name: "Agents scope" }), + ).toHaveTextContent( + "These are your agents imported into this community on this device.", + ); + await t.user.keyboard("{Escape}"); + await vi.waitFor(() => + expect(screen.queryByRole("dialog", { name: "Agents scope" })).toBeNull(), + ); + expect(screen.getByRole("dialog", { name: "Manage members" })).toBeVisible(); + await vi.waitFor(() => expect(badge).toHaveFocus()); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("keeps setup guidance behind its information popover", async () => { + const t = await setup(); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [{ pubkey: keypair().pubkey, name: "Fixture old agent" }], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + const group = await screen.findByRole("region", { + name: "Your other agents", + }); + const hint = "Your agents from other devices or earlier setups."; + expect(within(group).queryByText(hint)).toBeNull(); + const info = within(group).getByRole("button", { + name: "About your other agents", + }); + await t.user.click(info); + expect( + await screen.findByRole("dialog", { name: "About your other agents" }), + ).toHaveTextContent(hint); + await t.user.keyboard("{Escape}"); + await vi.waitFor(() => + expect( + screen.queryByRole("dialog", { name: "About your other agents" }), + ).toBeNull(), + ); + expect(screen.getByRole("dialog", { name: "Manage members" })).toBeVisible(); + await vi.waitFor(() => expect(info).toHaveFocus()); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("distinguishes invitation and roster People regions while retaining the caption", async () => { + const t = await setup(); + const refresh = screen.getByRole("button", { name: "Refresh member data" }); + await vi.waitFor(() => expect(refresh).toHaveAttribute("aria-busy", "false")); + t.confirmRoster(); + await t.user.click(refresh); + await screen.findByText("Morgan"); + const guest = profile(keypair(), { name: "Morgan Guest" }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [guest] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "Morgan"); + const invitations = await screen.findByRole("region", { + name: "People not in this channel", + }); + const roster = screen.getByRole("region", { name: "People" }); + expect(within(roster).getByText(nameText("Morgan"))).toBeVisible(); + expect( + within(invitations).getByRole("heading", { name: "People" }), + ).toBeVisible(); + expect( + within(invitations).getByRole("button", { name: /^Add Morgan Guest/ }), + ).toBeVisible(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("orders matches by client agents, people, other agents, then your non-client agents", async () => { + const t = await setup(); + const other = profile(keypair(), { name: "Fixture", is_agent: true }); + const person = profile(t.person, { name: "Fixture person" }); + const saved = { pubkey: keypair().pubkey, name: "Fixture saved" }; + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [saved], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [other, person] : query(filters), + ); + recordChoice( + t.session.scope, + pickerText("channel-members", "fixture"), + personKey(saved.pubkey), + ); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await screen.findByRole("button", { name: /^Add Fixture person/ }); + const results = screen.getByRole("region", { name: "Not in this channel" }); + const rows = within(results).getAllByRole("button", { name: /^Add / }); + const sections = [ + "Your agents", + "People not in this channel", + "Other agents", + "Your other agents", + ]; + expect(rows).toHaveLength(4); + expect( + sections.map((name) => + within(within(results).getByRole("region", { name })).getByRole( + "button", + { name: /^Add / }, + ), + ), + ).toEqual(rows); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Fixture agent/), + expect.stringMatching(/^Add Fixture person/), + expect.stringMatching(/^Add Fixture \(/), + expect.stringMatching(/^Add Fixture saved/), + ]); + expect( + screen.queryByRole("checkbox", { name: "Include other agents" }), + ).toBeNull(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +async function relationshipInvitations() { + const t = await setup(); + const people = [ + { ...keypair(), name: "Sam A stranger" }, + { ...keypair(), name: "Sam B shared" }, + { ...keypair(), name: "Sam C group" }, + { ...keypair(), name: "Sam D direct" }, + { ...keypair(), name: "Sam E direct" }, + { ...keypair(), name: "Sam" }, + ]; + const key = (index: number) => people[index]?.pubkey ?? ""; + const directId = "22222222-2222-4222-8222-222222222222"; + const otherDirectId = "33333333-3333-4333-8333-333333333333"; + const groupId = "44444444-4444-4444-8444-444444444444"; + const sharedId = "55555555-5555-4555-8555-555555555555"; + const events = [ + metadata(t.relay, directId, "Direct", undefined, [["t", "dm"]]), + roster(t.relay, directId, [t.viewer.pubkey, key(3)]), + metadata(t.relay, otherDirectId, "Other direct", undefined, [["t", "dm"]]), + roster(t.relay, otherDirectId, [t.viewer.pubkey, key(4)]), + metadata(t.relay, groupId, "Group", undefined, [["t", "dm"]]), + roster(t.relay, groupId, [t.viewer.pubkey, key(2), t.person.pubkey]), + metadata(t.relay, sharedId, "Shared"), + roster(t.relay, sharedId, [t.viewer.pubkey, key(1)]), + ]; + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? people.map((person) => profile(person, { name: person.name })) + : [ + ...(await query(filters)), + ...events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ], + ); + await act(async () => t.session.channels.refreshList?.()); + await vi.waitFor(() => + expect( + t.session.channels + .list() + .channels.find((channel) => channel.id === directId)?.participants, + ).toEqual([key(3)]), + ); + const rows = () => + within( + within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getByRole("region", { name: "People not in this channel" }), + ) + .getAllByRole("button", { name: /^Add / }) + .map((row) => row.getAttribute("aria-label")); + return { ...t, people, key, directId, otherDirectId, rows }; +} + +it("orders blank invitations by shared relationships and usage before the four-row limit", async () => { + const t = await setup(); + await t.user.click(screen.getByRole("button", { name: "Done" })); + const agents = ["Alpha", "Bravo", "Charlie", "Delta", "Echo", "Foxtrot"].map( + (name) => ({ ...t.agent, pubkey: keypair().pubkey, name }), + ); + const directId = "22222222-2222-4222-8222-222222222222"; + const direct = agents[5]?.pubkey ?? ""; + const used = agents[4]?.pubkey ?? ""; + const events = [ + metadata(t.relay, directId, "Direct", undefined, [["t", "dm"]]), + roster(t.relay, directId, [t.viewer.pubkey, direct]), + ]; + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => [ + ...(await query(filters)), + ...events.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ]); + await act(async () => { + t.control.set({ agents }); + await t.session.channels.refreshList?.(); + }); + await vi.waitFor(() => + expect( + t.session.channels + .list() + .channels.find((channel) => channel.id === directId)?.participants, + ).toEqual([direct]), + ); + recordChoice(t.session.scope, "", personKey(used)); + await t.user.click(t.trigger); + const group = await screen.findByRole("region", { name: "Your agents" }); + await vi.waitFor(() => + expect( + within(group) + .getAllByRole("button", { name: /^Add / }) + .map((row) => row.getAttribute("aria-label")), + ).toEqual([ + expect.stringMatching(/^Add Foxtrot/), + expect.stringMatching(/^Add Echo/), + expect.stringMatching(/^Add Alpha/), + expect.stringMatching(/^Add Bravo/), + ]), + ); + expect(screen.getByRole("searchbox")).toHaveValue(""); + expect( + within(group).getByRole("button", { name: "Show more" }), + ).toBeVisible(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("uses shared relationship bands inside People while exact names stay first", async () => { + const t = await relationshipInvitations(); + await t.user.type(screen.getByRole("searchbox"), "Sa"); + await screen.findByRole("button", { name: /^Add Sam D direct/ }); + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam D direct/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam C group/), + expect.stringMatching(/^Add Sam B shared/), + ]); + await t.user.type(screen.getByRole("searchbox"), "m"); + await screen.findByRole("button", { name: /^Add Sam \(/ }); + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam \(/), + expect.stringMatching(/^Add Sam D direct/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam C group/), + ]); +}); + +it("uses shared DM visit usage and picker-specific earlier choices", async () => { + const t = await relationshipInvitations(); + recordVisit(t.session.scope, `channel:${t.otherDirectId}`); + recordChoice(t.session.scope, pickerText("dm", "sa"), personKey(t.key(0))); + await t.user.type(screen.getByRole("searchbox"), "Sa"); + await screen.findByRole("button", { name: /^Add Sam E direct/ }); + expect(t.rows()[0]).toMatch(/^Add Sam E direct/); + recordChoice( + t.session.scope, + pickerText("channel-members", "sam"), + personKey(t.key(0)), + ); + await t.user.type(screen.getByRole("searchbox"), "m "); + await screen.findByRole("button", { name: /^Add Sam \(/ }); + // Exact still leads; the earlier Add choice precedes the relationship bands. + expect(t.rows()).toEqual([ + expect.stringMatching(/^Add Sam \(/), + expect.stringMatching(/^Add Sam A stranger/), + expect.stringMatching(/^Add Sam E direct/), + expect.stringMatching(/^Add Sam D direct/), + ]); +}); + +it("remembers successful channel additions without learning from failed writes", async () => { + const t = await setup(); + t.fail("Only the owner can add this agent"); + const add = await t.search(); + await t.user.click(add); + await screen.findByText(/Only the owner can add this agent/); + const typed = pickerText("channel-members", "morgan"); + const key = personKey(t.person.pubkey); + expect( + readSearchUsage(t.session.scope).pick(typed, new Set([key])), + ).toBeUndefined(); + t.fail(""); + await t.user.click(screen.getByRole("button", { name: "Retry" })); + await screen.findByText("Morgan is in the channel."); + expect(readSearchUsage(t.session.scope).pick(typed, new Set([key]))).toBe( + key, + ); +}); + +it("keeps public-key lookup results whose names do not match the key text", async () => { + const t = await setup(); + for (const value of [t.person.pubkey, npubEncode(t.person.pubkey)]) { + fireEvent.change(screen.getByRole("searchbox"), { target: { value } }); + const add = await screen.findByRole("button", { name: /^Add Morgan/ }); + expect(add).toBeEnabled(); + expect( + within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }), + ).toEqual([add]); + } + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("ranks a matching username with the shared people matcher", async () => { + const t = await setup(); + const handle = profile(keypair(), { display_name: "Alpha", name: "robin" }); + const named = profile(t.person, { name: "Robin Person" }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [named, handle] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "robin"); + await screen.findByRole("button", { name: /^Add Alpha/ }); + const rows = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Alpha/), + expect.stringMatching(/^Add Robin Person/), + ]); +}); + +it("ranks local usernames and hidden agent names through the shared matcher", async () => { + const t = await setup(); + const exact = keypair(), + word = keypair(), + shown = keypair(), + handle = keypair(); + const profiles = [ + profile(word, { name: "Helper Alpha", is_agent: true }), + profile(exact, { name: "Helper Zed", is_agent: true }), + profile(shown, { name: "Robin Visible", is_agent: true }), + profile(handle, { + display_name: "Handle Agent", + name: "robin", + is_agent: true, + }), + ]; + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [] + : [ + ...(await query(filters)), + ...profiles.filter((event) => + filters.some((filter) => matchesEvent(event, filter)), + ), + ], + ); + await act(async () => { + t.control.set({ + agents: [ + { ...t.agent, pubkey: word.pubkey, name: "Red Robin" }, + { ...t.agent, pubkey: exact.pubkey, name: "Robin" }, + { ...t.agent, pubkey: shown.pubkey, name: "Robin Visible" }, + { ...t.agent, pubkey: handle.pubkey, name: "Local Handle" }, + ], + }); + await t.session.profiles.ensure( + [word.pubkey, exact.pubkey, shown.pubkey, handle.pubkey], + "foreground", + ); + }); + await t.user.type(screen.getByRole("searchbox"), "robin"); + const yours = await screen.findByRole("region", { name: "Your agents" }); + const rows = within(yours).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Handle Agent/), + expect.stringMatching(/^Add Robin Visible/), + expect.stringMatching(/^Add Helper Zed/), + expect.stringMatching(/^Add Helper Alpha/), + ]); +}); + +it("keeps relay-only matches after shared-ranked names without duplicating rows", async () => { + const t = await setup(); + const relayOnly = profile(keypair(), { name: "A different name" }); + const named = profile(t.person, { name: "Robin" }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [relayOnly, named] + : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "robin"); + await screen.findByRole("button", { name: /^Add Robin/ }); + const rows = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getAllByRole("button", { name: /^Add / }); + expect(rows.map((row) => row.getAttribute("aria-label"))).toEqual([ + expect.stringMatching(/^Add Robin/), + expect.stringMatching(/^Add A different name/), + ]); +}); + +it("starts every invitation group at four and expands only the selected group", async () => { + const t = await setup(); + const client = Array.from({ length: 5 }, (_, index) => ({ + ...t.agent, + pubkey: keypair().pubkey, + name: `Helper client ${index}`, + })); + const saved = Array.from({ length: 5 }, (_, index) => ({ + pubkey: keypair().pubkey, + name: `Helper saved ${index}`, + })); + const people = Array.from({ length: 5 }, (_, index) => + profile(keypair(), { name: `Helper person ${index}` }), + ); + const other = Array.from({ length: 5 }, (_, index) => + profile(keypair(), { name: `Helper other ${index}`, is_agent: true }), + ); + t.readAgentLibrary.mockResolvedValue({ definitions: [], identities: saved }); + await act(async () => { + t.control.set({ agents: client }); + await t.session.agentChoices.refresh(); + }); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) + ? [...other, ...people] + : query(filters), + ); + const groups = [ + "Your agents", + "People not in this channel", + "Other agents", + "Your other agents", + ]; + const rows = (name: string) => + within(screen.getByRole("region", { name })).getAllByRole("button", { + name: /^Add /, + }); + expect(rows(groups[0] ?? "")).toHaveLength(4); + await t.user.type(screen.getByRole("searchbox"), "Helper"); + await screen.findByRole("button", { name: /^Add Helper person 0/ }); + for (const name of groups) { + expect(rows(name)).toHaveLength(4); + expect( + within(screen.getByRole("region", { name })).getByRole("button", { + name: "Show more", + }), + ).toBeVisible(); + } + await t.user.keyboard("{ArrowDown}{ArrowDown}{ArrowDown}{ArrowDown}"); + expect( + screen + .getByRole("region", { name: "Not in this channel" }) + .querySelector("[data-highlighted]"), + ).toContainElement(rows("People not in this channel")[0] ?? null); + await t.user.click( + within( + screen.getByRole("region", { name: "People not in this channel" }), + ).getByRole("button", { + name: "Show more", + }), + ); + expect(rows("People not in this channel")).toHaveLength(5); + expect( + within( + screen.getByRole("region", { name: "People not in this channel" }), + ).queryByRole("button", { name: "Show more" }), + ).toBeNull(); + for (const name of groups.filter( + (name) => name !== "People not in this channel", + )) + expect(rows(name)).toHaveLength(4); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("keeps library-only agents out of the default list and the client group", async () => { + const t = await setup(); + const old = profile(keypair(), { name: "Fixture old agent", is_agent: true }); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [t.agent, { pubkey: old.pubkey, name: "Fixture old agent" }], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + }); + const yours = screen.getByRole("region", { + name: "Your agents", + }); + expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( + 1, + ); + expect( + screen.queryByRole("button", { name: /^Add Fixture old agent/ }), + ).toBeNull(); + const query = t.query.getMockImplementation(); + if (!query) throw new Error("Missing query fixture"); + t.query.mockImplementation(async (filters) => + filters.some((filter) => filter.search) ? [old] : query(filters), + ); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await within( + await screen.findByRole("region", { name: "Your other agents" }), + ).findByRole("button", { name: /^Add Fixture old agent/ }); + expect(within(yours).getAllByRole("button", { name: /^Add / })).toHaveLength( + 1, + ); + await t.user.clear(screen.getByRole("searchbox")); + await act(async () => t.control.set({ agents: [] })); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); + expect(screen.queryByRole("button", { name: /^Add Fixture/ })).toBeNull(); + expect(t.publish).not.toHaveBeenCalled(); +}); + +it("does not promote saved agents after a client inventory failure and recovers", async () => { + const t = await setup(); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [t.agent], + }); + await act(async () => { + await t.session.agentChoices.refresh(); + t.control.set({ status: "error", error: "Client inventory offline" }); + }); + await screen.findByText("Some agents could not load."); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); + await act(async () => t.control.set({ status: "ready" })); + expect( + within(screen.getByRole("region", { name: "Your agents" })).getByRole( + "button", + { name: /^Add Fixture agent/ }, + ), + ).toBeVisible(); + expect(screen.queryByText("Some agents could not load.")).toBeNull(); +}); + +it("keeps archived agents out of the default invitations", async () => { + const t = await setup("stream", false, true, undefined, undefined, { + archivedAgent: true, + }); + await vi.waitFor(() => + expect(t.session.archives.snapshot().status).toBe("ready"), + ); + expect(screen.queryByRole("region", { name: "Your agents" })).toBeNull(); + await t.user.type(screen.getByRole("searchbox"), "Fixture"); + await vi.waitFor(() => + expect( + screen.getByRole("button", { name: "Refresh member data" }), + ).toHaveAttribute("aria-busy", "false"), + ); + expect( + screen.queryByRole("button", { name: /^Add Fixture agent/ }), + ).toBeNull(); +}); + it("searches outside the roster, prevents double submission, and moves confirmed additions into Members", async () => { const t = await setup(); const add = await t.search(); @@ -305,12 +939,8 @@ it("ranks a matching agent of yours by name and keeps the highlight on its row a .getByRole("region", { name: "Not in this channel" }) .querySelector("[data-highlighted]"); expect(highlighted()).toContainElement(morgan); - t.readAgentLibrary.mockResolvedValue({ - definitions: [], - identities: [agent], - }); await act(async () => { - await t.session.agentChoices.refresh(); + t.control.set({ agents: [{ ...t.agent, ...agent }] }); }); const rows = within( screen.getByRole("region", { name: "Not in this channel" }), @@ -355,7 +985,7 @@ it("DMs are view-only and never search outside their members", async () => { }); it("Escape closes the dialog and restores focus to its header button", async () => { const t = await setup(); - expect(screen.getByRole("searchbox")).toHaveFocus(); + await vi.waitFor(() => expect(screen.getByRole("searchbox")).toHaveFocus()); await t.user.keyboard("{Escape}"); await vi.waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument(), @@ -573,11 +1203,17 @@ it("refresh restarts the current directory query from page one instead of mixing : query(filters); }); await t.user.type(screen.getByRole("searchbox"), "Morgan"); - const more = await screen.findByRole("button", { name: "Show more results" }); + const more = await screen.findByRole("button", { name: "Find more matches" }); expect(more).toHaveAttribute("data-size", "sm"); expect(more.parentElement).toHaveClass("flex", "justify-center"); await t.user.click(more); + const people = within( + screen.getByRole("region", { name: "Not in this channel" }), + ).getByRole("region", { name: "People not in this channel" }); await screen.findByText(nameText("Morgan stale")); + expect(within(people).getAllByRole("button", { name: /^Add / })).toHaveLength( + 31, + ); const refresh = screen.getByRole("button", { name: "Refresh member data" }); await vi.waitFor(() => expect(refresh).toHaveAttribute("aria-busy", "false")); fresh = true; @@ -1083,6 +1719,8 @@ it("retries ownership admission and failed owner names through the shared refres canOpenLink: () => true, onOpenLink: () => true, }); + // Leave no default invitation view to release a slot during this budget test. + await act(async () => t.control.set({ agents: [] })); const manager = keypair(); const agent = managedProfile(t.person, manager, 1800000000); const original = t.query.getMockImplementation(); @@ -1148,6 +1786,8 @@ async function pagedInvitations( const local = Array.from({ length: localCount }, (_, index) => ({ pubkey: keypair().pubkey, name: `Helper local ${index}`, + owner: t.viewer.pubkey, + relayUrl: t.agent.relayUrl, })); const firstRemote = remote[0]; if (!firstRemote) throw new Error("Missing first remote fixture"); @@ -1160,6 +1800,12 @@ async function pagedInvitations( }); await step(() => act(async () => { + t.control.set({ + agents: [ + ...local, + { ...t.agent, pubkey: firstRemote.pubkey, name: "Helper remote 0" }, + ], + }); await t.session.agentChoices.refresh(); }), ); @@ -1194,7 +1840,27 @@ async function pagedInvitations( fireEvent.click(button); }); const more = () => - click(screen.getByRole("button", { name: "Show more results" })); + click(screen.getByRole("button", { name: "Find more matches" })); + const groupRows = (name: string) => + within(screen.getByRole("region", { name })).getAllByRole("button", { + name: /^Add /, + }); + // React retains the group's control while it expands. Resolve it once rather + // than recomputing every growing row's accessible name before each click. + const moreButtons = new Map(); + const moreIn = (name: string) => { + let button = moreButtons.get(name); + if (!button?.isConnected) { + button = within(screen.getByRole("region", { name })).getByRole( + "button", + { + name: "Show more", + }, + ); + moreButtons.set(name, button); + } + return click(button); + }; const rows = () => within( screen.getByRole("region", { name: "Not in this channel" }), @@ -1206,27 +1872,38 @@ async function pagedInvitations( .map((filter) => filter.page); await search("Helper"); await idle(); - return { t, refresh, idle, search, click, more, rows, pages }; + return { + t, + local, + remote, + refresh, + idle, + search, + click, + more, + moreIn, + groupRows, + rows, + pages, + }; } -it("pages combined local and relay invitations without losing matches", async ({ +it("expands local and relay groups independently without losing paged matches", async ({ signal, }) => { return ownedTask(signal, async (step) => { - const { t, idle, more, rows, pages } = await pagedInvitations(step, 65); - const firstPage = rows(); - expect(firstPage).toHaveLength(30); - expect( - firstPage.every((row) => - row.getAttribute("aria-label")?.startsWith("Add Helper remote"), - ), - ).toBe(true); + const { t, idle, more, moreIn, groupRows, rows, pages } = + await pagedInvitations(step, 65); + expect(rows()).toHaveLength(8); + expect(groupRows("Your agents")).toHaveLength(4); + expect(groupRows("People not in this channel")).toHaveLength(4); + for (let index = 0; index < 16; index++) await moreIn("Your agents"); + expect(groupRows("Your agents")).toHaveLength(66); + expect(groupRows("People not in this channel")).toHaveLength(4); + for (let index = 0; index < 7; index++) + await moreIn("People not in this channel"); + expect(rows()).toHaveLength(95); expect(pages()).toEqual([1]); - for (const count of [60, 90, 95]) { - await more(); - expect(rows()).toHaveLength(count); - expect(pages()).toEqual([1]); - } await more(); await idle(); expect( @@ -1235,20 +1912,87 @@ it("pages combined local and relay invitations without losing matches", async ({ expect(rows()).toHaveLength(96); expect(pages()).toEqual([1, 2]); expect( - screen.queryByRole("button", { name: "Show more results" }), + screen.queryByRole("button", { name: "Find more matches" }), ).toBeNull(); expect(t.publish).not.toHaveBeenCalled(); }); }); -it("resets combined invitation paging on refresh or query change", async ({ +it("reveals fetched relay pages while local-only rows keep their own limit", async ({ signal, }) => { return ownedTask(signal, async (step) => { - // 31 local agents: past one page locally, so a reset is observable. - const { t, refresh, idle, search, click, more, rows, pages } = - await pagedInvitations(step, 31); - for (let i = 0; i < 3; i++) await more(); + const { t, idle, more, moreIn, groupRows, rows, pages } = + await pagedInvitations(step, 65); + expect(rows()).toHaveLength(8); + await more(); + await idle(); + expect( + screen.getByRole("button", { name: /^Add Helper final/ }), + ).toBeVisible(); + expect(groupRows("People not in this channel")).toHaveLength(30); + expect(groupRows("Your agents")).toHaveLength(4); + await moreIn("Your agents"); + expect(groupRows("Your agents")).toHaveLength(8); + expect(groupRows("People not in this channel")).toHaveLength(30); + expect(pages()).toEqual([1, 2]); + expect(t.publish).not.toHaveBeenCalled(); + }); +}); + +it("expands a mixed group from its revealed relay boundary", async ({ + signal, +}) => { + return ownedTask(signal, async (step) => { + const { t, local, remote, idle, more, moreIn, groupRows } = + await pagedInvitations(step, 5); + t.readAgentLibrary.mockResolvedValue({ + definitions: [], + identities: [ + ...local, + ...remote.map((event, index) => ({ + pubkey: event.pubkey, + name: `Helper remote ${index}`, + })), + ], + }); + await step(() => + act(async () => { + t.control.set({ agents: [] }); + await t.session.agentChoices.refresh(); + }), + ); + await idle(); + expect(groupRows("Your other agents")).toHaveLength(4); + await more(); + await idle(); + expect(groupRows("Your other agents")).toHaveLength(30); + await moreIn("Your other agents"); + expect(groupRows("Your other agents")).toHaveLength(34); + expect(t.publish).not.toHaveBeenCalled(); + }); +}); + +it("resets every invitation group's expansion on refresh or query change", async ({ + signal, +}) => { + return ownedTask(signal, async (step) => { + const { + t, + refresh, + idle, + search, + click, + more, + moreIn, + groupRows, + rows, + pages, + } = await pagedInvitations(step, 31); + for (let index = 0; index < 7; index++) await moreIn("Your agents"); + for (let index = 0; index < 7; index++) + await moreIn("People not in this channel"); + await more(); await idle(); expect( screen.getByRole("button", { name: /^Add Helper final/ }), @@ -1257,17 +2001,18 @@ it("resets combined invitation paging on refresh or query change", async ({ expect(pages()).toEqual([1, 2]); await click(refresh); await idle(); - expect(rows()).toHaveLength(30); + expect(rows()).toHaveLength(8); expect(pages()).toEqual([1, 2, 1]); - await more(); - expect(rows()).toHaveLength(60); + await moreIn("Your agents"); + expect(groupRows("Your agents")).toHaveLength(8); + expect(groupRows("People not in this channel")).toHaveLength(4); await search("Helper local"); await idle(); - expect(rows()).toHaveLength(30); + expect(rows()).toHaveLength(8); await search(""); - expect( - screen.queryByRole("region", { name: "Not in this channel" }), - ).toBeNull(); + expect(groupRows("Your agents")).toHaveLength(4); + expect(rows()).toHaveLength(4); + expect(pages()).toEqual([1, 2, 1, 1]); expect(t.publish).not.toHaveBeenCalled(); }); }); @@ -1297,6 +2042,8 @@ it("adds a team's members not in the channel with one button", async () => { snapshot: () => snapshot, } as unknown as Agents; const t = await setup("stream", false, false, undefined, agents); + await screen.findByRole("region", { name: "Your agents" }); + expect(screen.queryByRole("list", { name: "Teams" })).toBeNull(); await t.user.type(screen.getByRole("searchbox"), "Cr"); const teams = await screen.findByRole("list", { name: "Teams" }); expect(within(teams).getByText("Ada, Cy")).toBeInTheDocument(); @@ -1323,15 +2070,13 @@ it("adds a team's members not in the channel with one button", async () => { }); it("offers local agents to add by the shared name rule", async () => { const t = await setup(); - t.readAgentLibrary.mockResolvedValue({ - definitions: [], - identities: [ - { pubkey: keypair().pubkey, name: "José" }, - { pubkey: keypair().pubkey, name: "Honey" }, - ], - }); await act(async () => { - await t.session.agentChoices.refresh(); + t.control.set({ + agents: [ + { ...t.agent, pubkey: keypair().pubkey, name: "José" }, + { ...t.agent, pubkey: keypair().pubkey, name: "Honey" }, + ], + }); }); const query = t.query.getMockImplementation(); if (!query) throw new Error("Missing query fixture"); diff --git a/src/bundled/channels/ChannelMembersDialog.tsx b/src/bundled/channels/ChannelMembersDialog.tsx index b1f5f1193d..c6fa963e36 100644 --- a/src/bundled/channels/ChannelMembersDialog.tsx +++ b/src/bundled/channels/ChannelMembersDialog.tsx @@ -20,9 +20,21 @@ import type { RelaySession } from "../../features/relay/session"; import type { Navigation } from "../../features/navigation/controller"; import type { Agents, Team } from "../../features/agents/service"; import { useAgentChoices } from "../../features/agents/use-choices"; +import { AgentCommunityScope } from "../../features/agents/AgentCommunityScope"; +import type { CommunityReader } from "../../features/communities/service"; +import { communityPresentation } from "../../features/communities/community-icon"; +import { splitPartition } from "../../features/relay/partition"; import { useIdentityNames } from "../../features/identity-names/react"; import { usePresenceStatus } from "../../features/presence/react"; import { profileTarget } from "../../features/profiles/target"; +import { peopleRelationships } from "../../features/profiles/relationships"; +import { personKey, rankPeople } from "../../features/search/people-rank"; +import { + noSearchUsage, + pickerText, + readSearchUsage, + recordChoice, +} from "../../features/search/usage"; import { matchPerson } from "../../features/search/person-match"; import { useSearchHighlight } from "../../features/search/use-search-highlight"; import styles from "./ChannelMembersDialog.module.css"; @@ -44,6 +56,7 @@ import { publicKeyLabels, } from "../../shared/identity/public-key"; import { Dialog } from "../../shared/design-system/ui/Dialog"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; import { IconButton } from "../../shared/design-system/ui/IconButton"; import { Button } from "../../shared/design-system/ui/Button"; import { Select } from "../../shared/design-system/ui/Select"; @@ -51,11 +64,17 @@ import { SearchField } from "../../shared/design-system/ui/SearchField"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import { Accordion } from "../../shared/design-system/ui/Accordion"; import { Tooltip } from "../../shared/design-system/ui/Tooltip"; +import { + PopoverRoot, + PopoverTrigger, + PopoverPopup, +} from "../../shared/design-system/ui/Popover"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { ArrowsClockwiseIcon, CircleNotchIcon, HashIcon, + InfoIcon, UsersIcon, XIcon, } from "../../shared/design-system/icons"; @@ -70,6 +89,8 @@ import { avatarMedia } from "../../shared/avatar-source"; const typedMatch = (name: string, needle: string) => needle ? matchPerson(name, needle) : undefined; +const INVITATION_GROUP_SIZE = 4; + /** Mounted rows share the same bounded presence owner as message bylines. */ function MemberAvatar({ session, @@ -322,6 +343,7 @@ export function ChannelMembersButton({ onOpenConversation, presentation, agents, + communities, navigator, signal, owner, @@ -333,6 +355,7 @@ export function ChannelMembersButton({ /** Someone else's Solo chat names its owner on the Access control. */ owner?: { name: string; picture?: string | undefined } | undefined; agents?: Agents | undefined; + communities?: CommunityReader | undefined; navigator?: Navigation | undefined; signal?: AbortSignal | undefined; presentation?: @@ -370,6 +393,7 @@ export function ChannelMembersButton({ onOpenLink={onOpenLink} onOpenConversation={onOpenConversation} navigator={navigator} + communities={communities} signal={signal} {...(agents ? { agents } : {})} close={() => setOpen(false)} @@ -443,6 +467,7 @@ export function ChannelMembersDialog({ onOpenLink, onOpenConversation, agents, + communities, navigator, signal, }: { @@ -453,6 +478,7 @@ export function ChannelMembersDialog({ title?: string; description?: string; agents?: Agents; + communities?: CommunityReader | undefined; navigator?: Navigation | undefined; /** The visit that opened this dialog; leaving it retires late results. */ signal?: AbortSignal | undefined; @@ -496,6 +522,26 @@ export function ChannelMembersDialog({ const focusedAdd = useRef<{ key: string; button: HTMLButtonElement } | null>( null, ); + const expandedFocus = useRef<{ + rowId: string; + button: HTMLButtonElement; + } | null>(null); + // Keyboard expansion can remove its focused control. Hand off after the rows + // mount, without overriding a surviving button or an intentional focus move. + useLayoutEffect(() => { + const previous = expandedFocus.current; + expandedFocus.current = null; + if ( + previous && + !previous.button.isConnected && + (document.activeElement === previous.button || + document.activeElement === document.body) + ) + document + .getElementById(previous.rowId) + ?.querySelector("button:not(:disabled)") + ?.focus(); + }); const lifetime = useRef(undefined); const [query, setQuery] = useState(""); const [selectedRole, setSelectedRole] = useState("All"); @@ -547,6 +593,20 @@ export function ChannelMembersDialog({ session.archives.snapshot, ); const agentChoices = useAgentChoices(session, true, true); + const communitySnapshot = useSyncExternalStore( + communities?.subscribe ?? noTeams, + communities?.snapshot ?? noSnapshot, + ); + const community = useMemo(() => { + const origin = splitPartition(session.scope)?.communityOrigin; + if (!origin || origin === "offline") return undefined; + try { + return communityPresentation(origin, communitySnapshot?.memberships); + } catch { + // An unscoped/offline reader has no community presentation to show. + return undefined; + } + }, [session.scope, communitySnapshot?.memberships]); const agentsSnapshot = useSyncExternalStore( agents?.subscribe ?? noTeams, agents?.snapshot ?? noSnapshot, @@ -584,12 +644,24 @@ export function ChannelMembersDialog({ : "" : rosterReadError; const text = query.trim(); + // biome-ignore lint/correctness/useExhaustiveDependencies: a new query or explicit refresh rereads stored choice/visit scores. + const usage = useMemo( + () => (solo ? noSearchUsage : readSearchUsage(session.scope)), + [session.scope, solo, text, refresh], + ); const [invitationPage, setInvitationPage] = useState({ text, size: MEMBER_SEARCH_PAGE_SIZE, + revealRelay: false, + groupSizes: {} as Record, }); if (invitationPage.text !== text) - setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE }); + setInvitationPage({ + text, + size: MEMBER_SEARCH_PAGE_SIZE, + revealRelay: false, + groupSizes: {}, + }); // biome-ignore lint/correctness/useExhaustiveDependencies: destination changes retire addition notices. useEffect(() => { const controller = new AbortController(); @@ -765,16 +837,27 @@ export function ChannelMembersDialog({ group.keys.length > 0, ); const search = useMemberSearch(session, query, canAddPeople); + const relayKeys = new Set(search.people.map((person) => person.pubkey)); const candidates = new Map( search.people.map((person) => [person.pubkey, person]), ); - if (text) + // Default suggestions use this client's agents; searches also include the + // viewer's non-client inventory after people and other community agents. + if (text || (!solo && canAdd)) for (const agent of solo ? canAddSoloAgents ? agentChoices.selectable : [] - : agentChoices.identities) { - if (addable(agent.pubkey, label(agent.pubkey, agent.name), agent.name)) + : agentChoices.selectable.filter((agent) => !!text || agent.managed)) { + if ( + !text || + addable( + agent.pubkey, + label(agent.pubkey, agent.name), + agent.name, + profiles.get(agent.pubkey)?.username, + ) + ) candidates.set(agent.pubkey, { pubkey: agent.pubkey, name: agent.name, @@ -782,10 +865,18 @@ export function ChannelMembersDialog({ }); } const needle = text.toLowerCase(); - // The relay ranks each page exact, then prefix, then other. Rank merged - // agents the same way; the stable sort keeps the relay's order within a rank. - const candidateRank = (person: { pubkey: string; name: string }) => - typedMatch(label(person.pubkey, person.name), needle)?.tier ?? 4; + const relationships = useMemo( + () => + peopleRelationships( + list.channels, + new Set( + agentChoices.identities + .filter((agent) => agent.managed) + .map((agent) => agent.pubkey), + ), + ), + [list.channels, agentChoices.identities], + ); const soloAgents = new Set( agentChoices.selectable.map((agent) => agent.pubkey), ); @@ -795,29 +886,110 @@ export function ChannelMembersDialog({ known.has(person.pubkey) || profiles.get(person.pubkey)?.isAgent ); - const available = [...candidates.values()] - .filter( - (person) => - !members.has(person.pubkey) && - !archiveHides(session.archives, person.pubkey, session.viewer) && - // A Solo chat offers only the agents its owner can add directly. - !( - solo && - agentCandidate(person) && - !(canAddSoloAgents && soloAgents.has(person.pubkey)) - ), - ) - .sort((a, b) => candidateRank(a) - candidateRank(b)); + const candidateGroup = (person: { pubkey: string; isAgent?: boolean }) => + known.get(person.pubkey)?.managed + ? 0 + : !agentCandidate(person) + ? 1 + : known.has(person.pubkey) + ? 3 + : 2; + const available = [...candidates.values()].filter( + (person) => + !members.has(person.pubkey) && + !archiveHides(session.archives, person.pubkey, session.viewer) && + // A Solo chat offers only the agents its owner can add directly. + !( + solo && + agentCandidate(person) && + !(canAddSoloAgents && soloAgents.has(person.pubkey)) + ), + ); + const picked = usage.pick( + pickerText("channel-members", needle), + new Set(available.map((person) => personKey(person.pubkey))), + ); + // Solo retains its existing combined, name-ranked page. + if (solo) + available.sort( + (a, b) => + (typedMatch(label(a.pubkey, a.name), needle)?.tier ?? 4) - + (typedMatch(label(b.pubkey, b.name), needle)?.tier ?? 4), + ); // Known agents supplement the server page, not its rendering bound. Keep all // matches reachable through the existing More action without mounting them all. const invitationSize = invitationPage.text === text ? invitationPage.size : MEMBER_SEARCH_PAGE_SIZE; - const visibleCandidates = - roleFilter === "All" ? available.slice(0, invitationSize) : []; - const moreCandidates = available.length > invitationSize; - const offering = !initialLoading && canAdd && roleFilter === "All" && !!text; + const moreCandidates = solo && available.length > invitationSize; + const offering = + !initialLoading && + canAdd && + roleFilter === "All" && + (!!text || (!solo && available.length > 0)); + const invitationGroups = [ + "Your agents", + "People", + "Other agents", + "Your other agents", + ].map((name, index) => { + const people = + !solo && roleFilter === "All" + ? available.filter((person) => candidateGroup(person) === index) + : []; + const ranked = rankPeople( + people.map((person) => { + const dm = relationships.dm(person.pubkey); + const own = known.get(person.pubkey); + return { + ...person, + label: label(person.pubkey, person.name), + username: person.username ?? profiles.get(person.pubkey)?.username, + agentName: own?.managed ? own.name : undefined, + agent: agentCandidate(person), + relationship: relationships.rank(person.pubkey), + usageKeys: [ + personKey(person.pubkey), + ...(dm ? [`channel:${dm}`] : []), + ], + }; + }), + needle, + usage, + picked, + ).map(({ person }) => person); + // Name ranking must not discard exact-key lookups or other relay matches + // (for example a profile's NIP-05). Keep those after the shared name matches. + const rankedKeys = new Set(ranked.map((person) => person.pubkey)); + const ordered = [ + ...ranked, + ...people.filter((person) => !rankedKeys.has(person.pubkey)), + ]; + // A directory continuation reveals its loaded matches, including the new + // page. Local-only rows retain the group's separate expansion limit. + const size = Math.max( + invitationPage.groupSizes[name] ?? INVITATION_GROUP_SIZE, + invitationPage.revealRelay + ? ordered.reduce( + (last, person, index) => + relayKeys.has(person.pubkey) ? index + 1 : last, + 0, + ) + : 0, + ); + return { + name, + people: ordered.slice(0, size), + more: ordered.length > size, + next: ordered[size]?.pubkey, + }; + }); + const visibleCandidates = solo + ? roleFilter === "All" + ? available.slice(0, invitationSize) + : [] + : invitationGroups.flatMap((group) => group.people); const agentsByMention = offering && solo && @@ -884,7 +1056,12 @@ export function ChannelMembersDialog({ if (solo) share.reloadRoster(); else setRosterReading(true); setRefresh((value) => value + 1); - setInvitationPage({ text, size: MEMBER_SEARCH_PAGE_SIZE }); + setInvitationPage({ + text, + size: MEMBER_SEARCH_PAGE_SIZE, + revealRelay: false, + groupSizes: {}, + }); void session.memberAdministration.refresh(channelId).catch(() => {}); // Native inventory retains ready data during refresh, so its snapshot alone // does not expose every in-flight read. @@ -904,7 +1081,7 @@ export function ChannelMembersDialog({ const missing = team.members.filter( (key) => teamNames.has(key) && !members.has(key), ); - return missing.length && matchPerson(team.name, query) + return text && missing.length && matchPerson(team.name, query) ? [{ team, missing }] : []; }); @@ -932,6 +1109,11 @@ export function ChannelMembersDialog({ async (key: string) => { const signal = lifetime.current?.signal; if (!signal) return; + // Capture this intent's text without changing the callback on each key. + const typed = pickerText( + "channel-members", + input.current?.value.trim().toLowerCase() ?? "", + ); setNotice(""); // A Solo owner's agents join directly; people use ordinary additions. const direct = @@ -954,6 +1136,7 @@ export function ChannelMembersDialog({ ); else await session.memberAdditions.add(channelId, key); if (!signal.aborted) { + if (!solo) recordChoice(session.scope, typed, personKey(key)); if (direct) { setSoloAdditions((old) => ({ ...old, [key]: { pending: false } })); setQuery(""); @@ -980,7 +1163,7 @@ export function ChannelMembersDialog({ if (direct) pending.delete(key); } }, - [session, channelId, canAddSoloAgents, place], + [session, channelId, canAddSoloAgents, place, solo], ); const addTeam = useCallback( @@ -1595,11 +1778,11 @@ export function ChannelMembersDialog({ aria-label={`Not in this ${place}`} {...highlight.listProps} > -

- Not in this {place} -

+ + {teams.length > 0 && (
    {teams.map(({ team, missing }) => ( @@ -1616,14 +1799,113 @@ export function ChannelMembersDialog({ ))}
)} - {visibleCandidates.map((person) => - row( - person.pubkey, - label(person.pubkey, person.name), - true, - person.picture, - person.isAgent, - ), + {(solo + ? [ + { + name: "", + people: visibleCandidates, + more: false, + next: undefined, + }, + ] + : invitationGroups + ).map((group) => + group.people.length > 0 ? ( +
+ {group.name && ( +

+ {group.name} + {group.name === "Your agents" && ( + + )} + {group.name === "Your other agents" && ( + + + )} +

+ )} +
    + {group.people.map((person) => + row( + person.pubkey, + label(person.pubkey, person.name), + true, + person.picture, + person.isAgent, + ), + )} +
+ {group.more && ( +
+ +
+ )} +
+ ) : null, )} {search.loading && (

)} {(moreCandidates || search.more) && ( -

+
)} diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 2b102703e5..c7bae6bb92 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -90,6 +90,7 @@ import { useChannelLabels } from "./useChannelLabels"; import { useComposerSent } from "./useComposerSent"; import { useSidebarPreferences } from "./useSidebarPreferences"; import styles from "./Channels.module.css"; +import type { CommunityReader } from "../../features/communities/service"; import { avatarMedia } from "../../shared/avatar-source"; /** The open Solo conversation's Archive, published for the shortcut while its visit is live. */ @@ -105,11 +106,13 @@ export function ChannelsPage({ navigation, navigator, agents, + communities, archive, }: { extensions?: ConversationExtensions | undefined; /** For adding a whole team of the owner's agents from the members dialog. */ agents?: Agents | undefined; + communities?: CommunityReader | undefined; relay: RelayData; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -158,6 +161,7 @@ export function ChannelsPage({ archive={archive} companion={companion} agents={agents} + communities={communities} /> )} @@ -176,11 +180,13 @@ function ChannelWorkspace({ navigator, viewer, agents, + communities, archive, }: { extensions?: ConversationExtensions | undefined; agents?: Agents | undefined; companion?: ReactNode; + communities?: CommunityReader | undefined; scope: string; navigation?: PageNavigation | undefined; navigator: Navigation; @@ -1544,6 +1550,7 @@ function ChannelWorkspace({ key={current.id} session={queries} channelId={current.id} + communities={communities} close={() => setMembersOpen(false)} trigger={ membersTrigger.current?.isConnected diff --git a/src/bundled/channels/index.tsx b/src/bundled/channels/index.tsx index 158a8fd631..5d7a2ee2e9 100644 --- a/src/bundled/channels/index.tsx +++ b/src/bundled/channels/index.tsx @@ -19,6 +19,7 @@ export const inject = [ "navigation", "settingsCards", "agents", + "communityReader", "shortcuts", ]; export const apply: PluginModule["apply"] = (ctx) => { @@ -111,6 +112,7 @@ export const apply: PluginModule["apply"] = (ctx) => { archive={archive} companion={companion} agents={agents} + communities={ctx.communityReader} /> ), }); diff --git a/src/features/agents/AgentCommunityScope.tsx b/src/features/agents/AgentCommunityScope.tsx index e5440aa405..a197fdda42 100644 --- a/src/features/agents/AgentCommunityScope.tsx +++ b/src/features/agents/AgentCommunityScope.tsx @@ -15,6 +15,7 @@ export type AgentScopeReader = Pick; const noSubscribe = () => () => {}; const descriptions = { + Agents: "These are your agents imported into this community on this device.", Memory: "Memories are specific to this community and aren’t shared with other communities.", Attention: diff --git a/src/features/panels/service.test.tsx b/src/features/panels/service.test.tsx index 96b107243f..fa19032f98 100644 --- a/src/features/panels/service.test.tsx +++ b/src/features/panels/service.test.tsx @@ -8,6 +8,7 @@ import { SettingsCardsService } from "../settings/service"; import { ShortcutsService } from "../shortcuts/service"; import { provideRelay } from "../relay/service"; import { provideNavigation } from "../navigation/service"; +import { emptySync } from "../communities/known-communities"; import * as channelsPlugin from "../../bundled/channels"; import * as githubPlugin from "../../bundled/github"; import { flush } from "../relay/testing"; @@ -40,6 +41,17 @@ it("independently unloads panels without removing the page or shared data", asyn addTool: () => {}, addAttentionSource: () => {}, }); + root.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), + }); // GitHub reads through gh where the host can run it; here it cannot. root.provide("host", {}); provideNavigation(root); diff --git a/src/features/search/people-rank.test.ts b/src/features/search/people-rank.test.ts index 2695c05de6..db08e92072 100644 --- a/src/features/search/people-rank.test.ts +++ b/src/features/search/people-rank.test.ts @@ -61,6 +61,36 @@ it("puts the earlier choice for this text and usage ahead, within the order", () expect(order(people, "dana", usage)).toEqual(["Dana Two", "Dana One"]); }); +it.each(["", " \t "])( + "orders idle suggestions by relationship and usage for %j", + (needle) => { + const people = [ + person("stranger", "Stranger"), + person("managed", "Managed", { agent: true, relationship: 2 }), + person("visited", "Visited", { agent: true, relationship: 2 }), + person("other-dm", "Other DM", { relationship: 0 }), + person("dm", "Direct", { + relationship: 0, + usageKeys: [personKey("dm"), "channel:dm"], + }), + ]; + const usage: SearchUsage = { + boost: (...keys) => + keys.includes(personKey("visited")) || keys.includes("channel:dm") + ? 1 + : 0, + pick: () => undefined, + }; + expect(order(people, needle, usage)).toEqual([ + "Direct", + "Other DM", + "Visited", + "Managed", + "Stranger", + ]); + }, +); + it("ranks a hidden agent name after shown names, as mentions do", () => { const people = [ person("agent", "Helper Bot", { diff --git a/src/features/search/people-rank.ts b/src/features/search/people-rank.ts index 0609e4ed67..5ae6abf671 100644 --- a/src/features/search/people-rank.ts +++ b/src/features/search/people-rank.ts @@ -24,6 +24,8 @@ export const personKey = (pubkey: string) => `person:${pubkey}`; * after every shown name, as `mentionMatch` ranks hidden aliases. */ export function personMatch(person: PersonCandidate, needle: string) { + // Blank suggestions have no exact name; relationship and usage order them. + if (!needle.trim()) return 1; const tier = (name: string | undefined) => name === undefined ? undefined : matchPerson(name, needle)?.tier; const shown = [tier(person.label), tier(person.username)].filter( diff --git a/tests/browser/channel-members-focus.fixture.tsx b/tests/browser/channel-members-focus.fixture.tsx index 8cfa55b823..9065bdec53 100644 --- a/tests/browser/channel-members-focus.fixture.tsx +++ b/tests/browser/channel-members-focus.fixture.tsx @@ -54,14 +54,18 @@ const scrollMembers = new URLSearchParams(location.search).has("scroll") const invitationScale = new URLSearchParams(location.search).has( "invitation-scale", ); -const inventory = invitationScale - ? Array.from({ length: 240 }, (_, index) => ({ - pubkey: keypair().pubkey, - name: `Helper ${String(index + 1).padStart(3, "0")}`, - })) - : solo - ? [{ pubkey: person.pubkey, name: "Morgan" }] - : []; +const invitationFocus = new URLSearchParams(location.search).has( + "invitation-focus", +); +const inventory = + invitationScale || invitationFocus + ? Array.from({ length: invitationScale ? 240 : 10 }, (_, index) => ({ + pubkey: keypair().pubkey, + name: `Helper ${String(index + 1).padStart(3, "0")}`, + })) + : solo + ? [{ pubkey: person.pubkey, name: "Morgan" }] + : []; const searchScale = new URLSearchParams(location.search).has("search-scale"); const managed = searchScale ? Array.from({ length: 60 }, (_, index) => ({ diff --git a/tests/browser/channel-members-focus.spec.mjs b/tests/browser/channel-members-focus.spec.mjs index c2dab9301f..bf8c3b00fe 100644 --- a/tests/browser/channel-members-focus.spec.mjs +++ b/tests/browser/channel-members-focus.spec.mjs @@ -277,10 +277,7 @@ test("member identity geometry and separate addition focus", async ({ expect(field.y - header.y - header.height).toBeCloseTo(12, 0); await expect(add).toHaveCSS("font-size", "12px"); expect((await add.boundingBox()).height).toBe(24); - const members = dialog.getByRole("region", { - name: "People", - exact: true, - }); + const members = dialog.getByRole("region", { name: "People", exact: true }); const others = dialog.getByRole("region", { name: "Not in this channel", exact: true, @@ -290,7 +287,14 @@ test("member identity geometry and separate addition focus", async ({ exact: true, }); await expect(members).toHaveCount(0); - await expect(others.getByRole("heading")).toHaveCSS("position", "sticky"); + await expect( + dialog + .getByRole("region", { + name: "People not in this channel", + exact: true, + }) + .getByRole("heading", { name: "People", exact: true }), + ).toHaveCSS("position", "sticky"); // With no roster matches, search results start at the top without an orphan divider. await expect(others).toHaveCSS("border-top-width", "0px"); expect((await others.boundingBox()).y).toBeCloseTo( @@ -726,14 +730,16 @@ test("large known-agent searches mount only the first invitation page", async ({ body: JSON.stringify({ counts, ...sample }), contentType: "application/json", }); - expect(counts.slice(0, 3)).toEqual([30, 30, 30]); + expect(counts.slice(0, 3)).toEqual([4, 4, 4]); // A random npub may contain "hell"; it cannot contain "hello" (no o in bech32). - expect(counts[3]).toBeLessThanOrEqual(30); + expect(counts[3]).toBeLessThanOrEqual(4); expect(counts.slice(4)).toEqual([0, 0]); await search.fill("helper"); await expect(refresh).toHaveAttribute("aria-busy", "false"); - const more = dialog.getByRole("button", { name: "Show more results" }); - for (const count of [60, 90, 120, 150, 180, 210, 240]) { + const more = dialog + .getByRole("region", { name: "Your other agents", exact: true }) + .getByRole("button", { name: "Show more", exact: true }); + for (let count = 8; count <= 240; count += 4) { await more.click(); await expect( dialog.getByRole("button", { name: /^Add Helper/ }), @@ -744,6 +750,53 @@ test("large known-agent searches mount only the first invitation page", async ({ expect(errors).toEqual([]); }); +// Browser-only: removing a focused button interacts with the native dialog focus +// trap differently in Chromium and WebKit; jsdom cannot establish the handoff. +test("final invitation expansion continues keyboard focus at the first new row", async ({ + page, +}) => { + const { errors } = watchPageErrors(page); + for (const activation of ["Enter", "Space", "pointer"]) { + await page.goto(`${url}?invitation-focus`); + await page.getByRole("button", { name: "Access" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage members" }); + await dialog.getByRole("searchbox").fill("Helper"); + const group = dialog.getByRole("region", { + name: "Your other agents", + exact: true, + }); + const more = group.getByRole("button", { name: "Show more", exact: true }); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(4); + await more.focus(); + await page.keyboard.press("Enter"); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(8); + await expect(more).toBeFocused(); + if (activation === "pointer") await more.click(); + else await page.keyboard.press(activation); + await expect( + group.getByRole("button", { name: /^Add Helper/ }), + ).toHaveCount(10); + await expect(more).toHaveCount(0); + const firstNew = group.getByRole("button", { + name: /^Open profile for Helper 009/, + }); + if (activation === "pointer") await expect(firstNew).not.toBeFocused(); + else { + await expect(firstNew).toBeFocused(); + await page.keyboard.press("Tab"); + await expect( + group.getByRole("button", { name: /^Add Helper 009/ }), + ).toBeFocused(); + } + } + expect(await page.evaluate(() => window.focusFixture.additions)).toEqual([]); + expect(errors).toEqual([]); +}); + // Browser-only: shared Select/modal focus, animated search geometry and native // full-height row action hit testing cannot be established by jsdom. test("member identity dropdown fits beside search and preserves modal focus", async ({ diff --git a/tests/fixtures/conversation.tsx b/tests/fixtures/conversation.tsx index 46cf5ae56e..37c52de08c 100644 --- a/tests/fixtures/conversation.tsx +++ b/tests/fixtures/conversation.tsx @@ -1,6 +1,7 @@ import { ToastProvider } from "../../src/shared/design-system/ui/Toast"; // Automated boundary proof only. No live broker, persistent messages or real keys. import { Context } from "@deepseek-ai/cordis"; +import { emptySync } from "../../src/features/communities/known-communities"; import { StrictMode, useState, @@ -291,6 +292,17 @@ conversation.tools.subscribe(() => { } }); ctx.provide("relay", relay); +ctx.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), +}); // Channels needs Agents for its members/templates; this fixture has no agents. const noAgents = { status: "unavailable", agents: [], running: [], teams: [] }; ctx.provide("agents", { diff --git a/tests/fixtures/mentions.tsx b/tests/fixtures/mentions.tsx index 6a22bd53a0..eca6b5719c 100644 --- a/tests/fixtures/mentions.tsx +++ b/tests/fixtures/mentions.tsx @@ -5,6 +5,7 @@ import { StrictMode, useState, useLayoutEffect } from "react"; import { createRoot } from "react-dom/client"; import { finalizeEvent } from "nostr-tools"; import { Context } from "@deepseek-ai/cordis"; +import { emptySync } from "../../src/features/communities/known-communities"; import { createPluginManager } from "../../src/plugins/manager"; import { PagesService } from "../../src/features/pages/service"; import { PanelsService } from "../../src/features/panels/service"; @@ -369,6 +370,17 @@ if (channels) { provideNavigation(context); // Channels requires Agents for its page/settings, neither mounted here. context.provide("agents", {}); + context.provide("communityReader", { + subscribe: () => () => {}, + snapshot: () => ({ + status: "unavailable", + relayAvailable: false, + profile: { name: "", picture: "" }, + memberships: [], + selected: null, + sync: emptySync(), + }), + }); // Only the composer is mounted; the Channels page never consumes this service. context.provide("relay", { snapshot: () => ({ session: namedSession }),