diff --git a/src/bundled/bestie/BestieTab.test.tsx b/src/bundled/bestie/BestieTab.test.tsx index 95c51d0f7..7f4a155e5 100644 --- a/src/bundled/bestie/BestieTab.test.tsx +++ b/src/bundled/bestie/BestieTab.test.tsx @@ -16,6 +16,7 @@ afterEach(() => { const OWNER = "f".repeat(64); const SCOPE = `https://relay.example:${OWNER}`; +const NO_CHANNELS = { channels: [] }; const member = (name: string, digit: string): Agent => ({ key: digit.repeat(64), @@ -64,6 +65,12 @@ function community(saved: string | undefined, agents = [ada, bea]) { status: "ready", scope: SCOPE, session: { + viewer: OWNER, + channels: { + list: () => NO_CHANNELS, + subscribeList: () => () => {}, + ensureList: () => () => {}, + }, mainAgent, agentChoices: { snapshot: () => choices, @@ -111,7 +118,7 @@ it("shows on the saved Bestie only, and moves when Change picks another", async expect(screen.getByText("This is your Bestie")).toBeVisible(); // A type without instructions has nothing to switch. expect(screen.getByText("Remembers you")).toBeVisible(); - expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull(); changed.mockClear(); await user.click(screen.getByRole("button", { name: "Change" })); // Change offers every other agent you can make Bestie. @@ -250,7 +257,7 @@ it("says why a save failed and lets the switch be tried again", async () => { "Connect to the community to save", ); expect(remembers).not.toBeChecked(); - expect(remembers).toBeEnabled(); + expect(remembers).not.toHaveAttribute("aria-disabled", "true"); // A config with no instructions yet gets the section alone. expect(save).toHaveBeenCalledWith(ada.key, { config: { instructions: withFeature("", memory, true) }, @@ -272,7 +279,7 @@ it("offers no switch while the agent's instructions can't be read here", () => { } />, ); - expect(screen.queryByRole("switch")).toBeNull(); + expect(screen.queryByRole("switch", { name: "Remembers you" })).toBeNull(); expect( screen.getByText("This agent's instructions could not be read"), ).toBeVisible(); diff --git a/src/bundled/bestie/BestieTab.tsx b/src/bundled/bestie/BestieTab.tsx index d935a610f..1bfdce44f 100644 --- a/src/bundled/bestie/BestieTab.tsx +++ b/src/bundled/bestie/BestieTab.tsx @@ -32,6 +32,7 @@ import { restoredFeature, withFeature, } from "./features"; +import { PrivateChannels } from "./PrivateChannels"; type TabAgents = Pick; @@ -169,11 +170,8 @@ function BestieTab({ title="Check-ins" subtitle="Every hour. Not set up yet." /> - + {bestie.view} ); diff --git a/src/bundled/bestie/PrivateChannels.test.tsx b/src/bundled/bestie/PrivateChannels.test.tsx new file mode 100644 index 000000000..5e4f1b049 --- /dev/null +++ b/src/bundled/bestie/PrivateChannels.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment jsdom +import "@testing-library/jest-dom/vitest"; +import { cleanup, render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, expect, it, vi } from "vitest"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import type { RelaySession } from "../../features/relay/session"; +import { PrivateChannels, privateChannels } from "./PrivateChannels"; + +afterEach(cleanup); + +const YOU = "f".repeat(64); +const BESTIE = "b".repeat(64); +const OTHER = "c".repeat(64); +const channel = ( + name: string, + members: readonly string[], + extra: Partial = {}, +): ChannelSummary => ({ + id: `id-${name}`, + name, + channelType: "stream", + private: true, + members, + ...extra, +}); + +it("keeps only your private channels, split by Bestie's membership, most recent first", () => { + const { joined, others } = privateChannels( + [ + channel("quiet", [YOU]), + channel("busy", [YOU], { lastActivityAt: 20 }), + channel("older", [YOU], { lastActivityAt: 10 }), + channel("shared", [YOU, BESTIE]), + channel("forum", [YOU], { channelType: "forum" }), + { + id: "id-public", + name: "public", + channelType: "stream", + members: [YOU], + }, + channel("not-yours", [OTHER, BESTIE]), + channel("dm", [YOU, BESTIE], { channelType: "dm" }), + channel("solo", [YOU, BESTIE], { channelType: "solo" }), + channel("archived", [YOU], { archived: true }), + channel("huddle", [YOU], { huddle: true }), + ], + YOU, + BESTIE, + ); + expect(joined.map(({ name }) => name)).toEqual(["shared"]); + expect(others.map(({ name }) => name)).toEqual([ + "busy", + "older", + "forum", + "quiet", + ]); +}); + +function session(channels: ChannelSummary[], removal = "confirmed") { + const roles: Record = { [BESTIE]: "bot" }; + const add = vi.fn(async (id: string) => { + if (id === "id-beta") throw new Error("Not confirmed. Try again."); + }); + const run = vi.fn(async () => {}); + const list = { channels }; + const value = { + viewer: YOU, + outbox: { supports: () => true }, + channels: { + list: () => list, + subscribeList: () => () => {}, + ensureList: () => () => {}, + }, + memberAdditions: { add }, + memberAdministration: { + refresh: vi.fn(async () => {}), + run, + snapshot: vi.fn(() => + run.mock.calls.length + ? { + status: "ready", + authority: { roles: {} }, + operation: { + change: { pubkey: BESTIE, role: "remove" }, + status: removal, + }, + ...(removal !== "confirmed" && { + error: "The change may have taken effect. Refresh to check.", + }), + } + : { status: "ready", authority: { roles } }, + ), + }, + } as unknown as RelaySession; + return { value, add, run }; +} + +it("removes Bestie from a channel it is in, as the Members dialog does", async () => { + const user = userEvent.setup(); + const { value, run } = session([channel("shared", [YOU, BESTIE])]); + render(); + await user.click( + screen.getByRole("button", { name: "Remove Bestie from #shared" }), + ); + expect(run).toHaveBeenCalledWith("id-shared", { + pubkey: BESTIE, + expectedRole: "bot", + role: "remove", + }); + expect(screen.queryByRole("alert")).toBeNull(); + // Creating channels with Bestie is shown, not offered yet. + expect( + screen.getByRole("switch", { + name: "Also add Bestie to private channels I create", + }), + ).toHaveAttribute("aria-disabled", "true"); +}); + +it("adds Bestie to one channel, or to all after asking, and reports what didn't confirm", async () => { + const user = userEvent.setup(); + const { value, add } = session([ + channel("alpha", [YOU], { lastActivityAt: 5 }), + channel("beta", [YOU], { lastActivityAt: 9 }), + ]); + render(); + expect(screen.getByText(/Not in any of your private channels/)).toBeVisible(); + + await user.click(screen.getByRole("button", { name: "Choose…" })); + const items = await screen.findAllByRole("menuitem"); + expect(items.map((item) => item.textContent)).toEqual(["#beta", "#alpha"]); + await user.click(screen.getByRole("menuitem", { name: "#alpha" })); + expect(add).toHaveBeenCalledWith("id-alpha", BESTIE); + + add.mockClear(); + expect(screen.getByText(/2 channels without Bestie/)).toBeVisible(); + expect( + screen.getByRole("button", { name: "Add to all" }), + ).toHaveAccessibleDescription("Add to all my private channels"); + await user.click(screen.getByRole("button", { name: "Add to all" })); + const ask = await screen.findByRole("alertdialog", { + name: "Add Bestie to 2 private channels?", + }); + expect(add).not.toHaveBeenCalled(); + await user.click(within(ask).getByRole("button", { name: "Add to all" })); + expect(add.mock.calls).toEqual([ + ["id-beta", BESTIE], + ["id-alpha", BESTIE], + ]); + expect(await screen.findByRole("alert")).toHaveTextContent( + "#beta: Not confirmed. Try again.", + ); +}); + +it("says when a removal isn't confirmed, and offers none from a cached roster", async () => { + const user = userEvent.setup(); + const { value } = session( + [ + channel("shared", [YOU, BESTIE]), + channel("offline", [YOU, BESTIE], { cached: true }), + ], + "uncertain", + ); + render(); + expect( + screen.getByRole("button", { name: "Remove Bestie from #offline" }), + ).toBeDisabled(); + await user.click( + screen.getByRole("button", { name: "Remove Bestie from #shared" }), + ); + expect(await screen.findByRole("alert")).toHaveTextContent( + "#shared: The change may have taken effect. Refresh to check.", + ); +}); diff --git a/src/bundled/bestie/PrivateChannels.tsx b/src/bundled/bestie/PrivateChannels.tsx new file mode 100644 index 000000000..bb3510559 --- /dev/null +++ b/src/bundled/bestie/PrivateChannels.tsx @@ -0,0 +1,248 @@ +import { useState } from "react"; +import { canAddMembers } from "../../features/channel-members/members"; +import type { ChannelSummary } from "../../features/relay/contracts"; +import { useChannelList } from "../../features/relay/react"; +import type { RelaySession } from "../../features/relay/session"; +import { AlertDialog } from "../../shared/design-system/ui/AlertDialog"; +import { Button } from "../../shared/design-system/ui/Button"; +import { InlineHeader } from "../../shared/design-system/ui/Header"; +import { + MenuItem, + MenuPopup, + MenuRoot, + MenuTrigger, +} from "../../shared/design-system/ui/Menu"; +import { PreferenceRow } from "../../shared/design-system/ui/PreferenceRow"; +import { SettingsGroup } from "../../shared/design-system/ui/SettingsGroup"; +import { SwitchPreferenceRow } from "../../shared/design-system/ui/SwitchPreferenceRow"; +import { compareRecent } from "../channels/sidebar-sections"; + +/** The private channels `viewer` is in, most recent first: those `bestie` is + * in too, and the rest. DMs, Solo chats, huddle rooms and system channels + * are not ones to add an agent to. */ +export function privateChannels( + channels: readonly ChannelSummary[], + viewer: string, + bestie: string, +) { + const yours = channels + .filter( + (channel) => + !channel.archived && + !channel.huddle && + (channel.channelType === "stream" || channel.channelType === "forum") && + channel.private && + channel.members?.includes(viewer), + ) + .sort(compareRecent); + return { + joined: yours.filter((channel) => channel.members?.includes(bestie)), + others: yours.filter((channel) => !channel.members?.includes(bestie)), + }; +} + +/** Removes `pubkey` from one channel as the Members dialog does, resolving + * only once a fresh roster read confirms it. */ +async function removeMember( + session: RelaySession, + channelId: string, + pubkey: string, +) { + const admin = session.memberAdministration; + await admin.refresh(channelId); + const read = admin.snapshot(channelId); + if (read.status !== "ready") + throw new Error(read.error ?? "Member roles are unavailable. Try again."); + const expectedRole = read.authority.roles[pubkey]; + // Already gone: nothing to send. + if (!expectedRole) return; + await admin.run(channelId, { pubkey, expectedRole, role: "remove" }); + const { operation, error } = admin.snapshot(channelId); + if (operation?.change.pubkey !== pubkey || operation.status !== "confirmed") + throw new Error(error ?? "The removal isn't confirmed. Try again."); +} + +const message = (error: unknown) => + error instanceof Error ? error.message : String(error); + +/** The private channels Bestie reads because it is a member. */ +export function PrivateChannels({ + session, + bestie, +}: { + session: RelaySession; + bestie: string; +}) { + const list = useChannelList(session.channels); + const { joined, others } = privateChannels( + list.channels, + session.viewer ?? "", + bestie, + ); + const addable = others.filter((channel) => canAddMembers(session, channel)); + const [busy, setBusy] = useState>(new Set()); + /** Why each channel's last change failed, and whether it was to join. */ + const [failed, setFailed] = useState< + Readonly< + Record> + > + >({}); + /** The channels Add to all asked about. */ + const [confirming, setConfirming] = useState(); + + /** Runs `change` on each channel in turn, as adding several agents does, + * and keeps why each one failed. */ + const run = async ( + channels: readonly ChannelSummary[], + join: boolean, + change: (id: string) => Promise, + ) => { + const ids = new Set(channels.map((channel) => channel.id)); + setBusy((current) => new Set([...current, ...ids])); + setFailed((current) => + Object.fromEntries( + Object.entries(current).filter(([id]) => !ids.has(id)), + ), + ); + for (const { id, name } of channels) { + let error: string | undefined; + try { + await change(id); + } catch (cause) { + error = message(cause); + } + setBusy((current) => new Set([...current].filter((key) => key !== id))); + if (error !== undefined) + setFailed((current) => ({ ...current, [id]: { name, error, join } })); + } + }; + const add = (channels: readonly ChannelSummary[]) => + run(channels, true, (id) => session.memberAdditions.add(id, bestie)); + const remove = (channel: ChannelSummary) => + run([channel], false, (id) => removeMember(session, id, bestie)); + const adding = addable.some((channel) => busy.has(channel.id)); + // A failure stops showing once the channel is as the change wanted anyway. + const inBestie = new Set(joined.map((channel) => channel.id)); + const problems = Object.entries(failed).filter( + ([id, { join }]) => inBestie.has(id) !== join, + ); + + return ( + <> + + + {joined.map((channel) => ( + void remove(channel)} + > + Remove + + } + /> + ))} + {!joined.length && ( + + )} + {/* Each action has a row of its own, so its button can wrap under + its title in a narrow panel instead of widening the tab. */} + {addable.length > 0 && ( + <> + ( + + + Choose… + + } + /> + + {addable.map((channel) => ( + void add([channel])} + > + #{channel.name} + + ))} + + + )} + /> + ( + + )} + /> + + )} + + + {problems.length > 0 && ( +
+ {problems.map(([id, { name, error }]) => ( +

+ #{name}: {error} +

+ ))} +
+ )} + {confirming && ( + setConfirming(undefined)} + actions={ + <> + + + + } + /> + )} + + ); +} diff --git a/src/bundled/bestie/README.md b/src/bundled/bestie/README.md index 55028ca18..3bee92588 100644 --- a/src/bundled/bestie/README.md +++ b/src/bundled/bestie/README.md @@ -27,7 +27,16 @@ it never adds a second one. **Restore** puts back the original section in place of its two markers and what lies between them, when they still show where it starts and ends; otherwise fix its markers in Instructions. Off deletes no memory, and the agent's core memory is still in every prompt; -clear memory in the Memory tab. The tab's other settings are not set up yet. +clear memory in the Memory tab. + +**Private channels Bestie can read** lists the private channels you share with +Bestie, each with **Remove**. **Add to a channel** (**Choose…**) and **Add to +all my private channels** add it to the private channels you are in, most +recently active first; each channel's members see that you added it. Each +action has a row of its own, so in a narrow panel its button wraps under it. +Public channels +need nothing: agents read them already. Check-ins and adding Bestie to new +channels you create are not set up yet. --- diff --git a/src/bundled/channels/sidebar-sections.ts b/src/bundled/channels/sidebar-sections.ts index 9e184c774..46b728ef0 100644 --- a/src/bundled/channels/sidebar-sections.ts +++ b/src/bundled/channels/sidebar-sections.ts @@ -29,6 +29,30 @@ export function orderContributedSidebarSections( .map(({ section }) => section); } +function compareName(a: ChannelSummary, b: ChannelSummary) { + return a.name.toLowerCase() < b.name.toLowerCase() + ? -1 + : a.name.toLowerCase() > b.name.toLowerCase() + ? 1 + : a.id.localeCompare(b.id); +} + +/** The sidebar's "recent" order: newest activity first, channels without known + * activity after, then by name. */ +export function compareRecent(a: ChannelSummary, b: ChannelSummary) { + if ( + a.lastActivityAt !== undefined && + b.lastActivityAt !== undefined && + a.lastActivityAt !== b.lastActivityAt + ) + return b.lastActivityAt - a.lastActivityAt; + if (a.lastActivityAt !== undefined && b.lastActivityAt === undefined) + return -1; + if (a.lastActivityAt === undefined && b.lastActivityAt !== undefined) + return 1; + return compareName(a, b); +} + /** Preferences only arrange the supplied authorized roster; they never add channels. */ export function sidebarSections( channels: readonly T[], @@ -51,28 +75,10 @@ export function sidebarSections( const groups = preferences?.sections ?? []; const ids = new Set(groups.map((group) => group.id)); const assignment = (id: string) => preferences?.assignments[id]; - const compareName = (a: ChannelSummary, b: ChannelSummary) => - a.name.toLowerCase() < b.name.toLowerCase() - ? -1 - : a.name.toLowerCase() > b.name.toLowerCase() - ? 1 - : a.id.localeCompare(b.id); const sort = (rows: readonly ChannelSummary[], key: string) => - [...rows].sort((a, b) => { - if (preferences?.sort?.[key] === "recent") { - if ( - a.lastActivityAt !== undefined && - b.lastActivityAt !== undefined && - a.lastActivityAt !== b.lastActivityAt - ) - return b.lastActivityAt - a.lastActivityAt; - if (a.lastActivityAt !== undefined && b.lastActivityAt === undefined) - return -1; - if (a.lastActivityAt === undefined && b.lastActivityAt !== undefined) - return 1; - } - return compareName(a, b); - }); + [...rows].sort( + preferences?.sort?.[key] === "recent" ? compareRecent : compareName, + ); return [ { key: "starred",