Skip to content
140 changes: 140 additions & 0 deletions app/src/features/account/ArchivedSettings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
// Settings, "Archived": the bots and crews the owner archived, which the
// rest of the app does not show, with a way to delete each for good
// (spec 7.6).

import { Users } from "lucide-react";
import { type ReactNode, useEffect, useState } from "react";
import { useT } from "../../i18n";
import { errorText } from "../../lib/api";
import { fromNow } from "../../lib/format";
import type { Archive, Bot, Crew } from "../../lib/protocol.gen";
import { useApi, useApp } from "../../store/context";
import { Button } from "../../ui/Button";
import { Callout } from "../../ui/Callout";
import { BotAvatar } from "../bots/BotAvatar";
import { DeleteBot } from "../bots/DeleteBot";
import { DeleteCrew } from "../crews/DeleteCrew";
import { Section } from "./settingsParts";

type Asking = { bot: Bot } | { crew: Crew } | null;

export function ArchivedSettings() {
const s = useT().account.settings;
const api = useApi();
const active = useApp((state) => state.crews);
const [archive, setArchive] = useState<Archive | null>(null);
const [failed, setFailed] = useState<string | null>(null);
const [asking, setAsking] = useState<Asking>(null);

useEffect(() => {
let alive = true;
api.call("archive.list").then(
(list) => alive && setArchive(list),
(error) => alive && setFailed(errorText(error)),
);
return () => {
alive = false;
};
}, [api]);

if (failed) {
return (
<Section title={s.archived}>
<Callout tone="danger" title={s.archivedLoadFailed}>
{failed}
</Callout>
</Section>
);
}
if (!archive) {
return <Section title={s.archived}>{null}</Section>;
}

// A bot of an archived crew goes with its crew; the others are listed.
const botsOf = (crew: Crew) => archive.bots.filter((bot) => bot.crewId === crew.id);
const bots = archive.bots.filter((bot) => !archive.crews.some((crew) => crew.id === bot.crewId));
const forget = (gone: Asking) =>
setArchive({
crews: archive.crews.filter((crew) => !(gone && "crew" in gone && gone.crew.id === crew.id)),
bots: archive.bots.filter(
(bot) =>
!(gone && "bot" in gone && gone.bot.id === bot.id) &&
!(gone && "crew" in gone && gone.crew.id === bot.crewId),
),
});

return (
<Section title={s.archived}>
<p className="text-ink-soft text-sm leading-relaxed">{s.archivedIntro}</p>
{archive.crews.length + bots.length === 0 ? (
<p className="text-muted text-sm">{s.archivedEmpty}</p>
) : (
<ul aria-label={s.archived} className="flex flex-col divide-y divide-line">
{archive.crews.map((crew) => (
<Row
key={crew.id}
icon={
<span className="grid size-7 place-items-center rounded-full bg-sunken text-muted">
<Users aria-hidden size={14} />
</span>
}
name={crew.name}
line={s.archivedCrew(botsOf(crew).length, fromNow(crew.archivedAt ?? 0))}
onDelete={() => setAsking({ crew })}
/>
))}
{bots.map((bot) => (
<Row
key={bot.id}
icon={<BotAvatar color={bot.color} size={28} />}
name={bot.name}
line={s.archivedBot(active[bot.crewId]?.name ?? "", fromNow(bot.archivedAt ?? 0))}
onDelete={() => setAsking({ bot })}
/>
))}
</ul>
)}
{asking && "bot" in asking && (
<DeleteBot
bot={asking.bot}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
{asking && "crew" in asking && (
<DeleteCrew
crew={asking.crew}
bots={botsOf(asking.crew).length}
onClose={() => setAsking(null)}
onDeleted={() => forget(asking)}
/>
)}
</Section>
);
}

function Row({
icon,
name,
line,
onDelete,
}: {
icon: ReactNode;
name: string;
line: string;
onDelete(): void;
}) {
const s = useT().account.settings;
return (
<li className="flex items-center gap-3 py-2">
{icon}
<div className="min-w-0 flex-1">
<p className="truncate font-medium text-sm">{name}</p>
<p className="truncate text-muted text-xs">{line}</p>
</div>
<Button variant="danger" size="sm" label={s.deleteArchivedOne(name)} onClick={onDelete}>
{s.deleteArchived}
</Button>
</li>
);
}
9 changes: 7 additions & 2 deletions app/src/features/account/SettingsDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
// Settings (spec 15.1), in parts listed on the left: general (what
// Botloft does in the background, the language), the chat, notifications,
// the look, and the versions for when something needs reporting.
// the look, what was archived, and the versions for when something needs
// reporting.

import {
Archive,
Bell,
Info,
type LucideIcon,
Expand All @@ -16,16 +18,18 @@ import { Dialog } from "../../ui/Dialog";
import { AboutSettings } from "./AboutSettings";
import { AlertsSettings } from "./AlertsSettings";
import { AppearanceSettings } from "./AppearanceSettings";
import { ArchivedSettings } from "./ArchivedSettings";
import { ChatSettings } from "./ChatSettings";
import { GeneralSettings } from "./GeneralSettings";

type Page = "general" | "chat" | "alerts" | "appearance" | "about";
type Page = "general" | "chat" | "alerts" | "appearance" | "archived" | "about";

const PAGES: { id: Page; icon: LucideIcon }[] = [
{ id: "general", icon: SlidersHorizontal },
{ id: "chat", icon: MessageSquare },
{ id: "alerts", icon: Bell },
{ id: "appearance", icon: Palette },
{ id: "archived", icon: Archive },
{ id: "about", icon: Info },
];

Expand Down Expand Up @@ -74,6 +78,7 @@ export function SettingsDialog({ onClose }: { onClose(): void }) {
{page === "chat" && <ChatSettings />}
{page === "alerts" && <AlertsSettings />}
{page === "appearance" && <AppearanceSettings />}
{page === "archived" && <ArchivedSettings />}
{page === "about" && <AboutSettings />}
</div>
</div>
Expand Down
90 changes: 90 additions & 0 deletions app/src/features/account/archived.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { cleanup, fireEvent, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, test } from "vitest";
import { FakeBotloft } from "../../lib/fake";
import { RpcError } from "../../lib/rpc";
import { crewOpened, renderApp } from "../../test/app";

afterEach(cleanup);

/** Opens Settings on its "Archived" part. */
async function openArchived() {
fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ }));
fireEvent.click(screen.getByRole("menuitem", { name: "Settings" }));
const dialog = screen.getByRole("dialog", { name: "Settings" });
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
return dialog;
}

/** Ops with Writer and an archived Scout, and an archived crew Docs of one bot. */
async function someArchived() {
const fake = new FakeBotloft();
const ops = fake.addCrew("Ops");
const docs = fake.addCrew("Docs");
const scout = fake.addBot(ops.id, "Scout");
fake.addBot(ops.id, "Writer");
const editor = fake.addBot(docs.id, "Editor");
await fake.call("bots.archive", { botId: scout.id });
await fake.call("crews.archive", { crewId: docs.id });
renderApp(fake);
await crewOpened("Ops");
return { fake, ops, docs, scout, editor };
}

describe("settings, archived", () => {
test("lists what was archived, which the app shows nowhere else", async () => {
await someArchived();
const dialog = await openArchived();
const list = await within(dialog).findByRole("list", { name: "Archived" });
const rows = within(list).getAllByRole("listitem");
expect(rows.map((row) => row.textContent)).toEqual([
"DocsCrew · 1 bot · archived nowDelete",
"ScoutBot in Ops · archived nowDelete",
]);
// A bot of an archived crew goes with its crew, not on its own.
expect(within(list).queryByText("Editor")).toBeNull();
expect(dialog.textContent).toContain("Botloft still keeps their conversations");
});

test("deletes an archived bot, saying it leaves rather than stops", async () => {
const { fake, scout } = await someArchived();
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Scout" }));
const confirm = screen.getByRole("dialog", { name: "Delete Scout?" });
expect(confirm.textContent).toContain("Scout leaves Botloft for good");
expect(confirm.textContent).not.toContain("stops now");
expect(within(confirm).getByText(scout.workspace)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete bot" }));
await waitFor(() => expect(within(dialog).queryByText("Scout")).toBeNull());
expect(fake.bots.has(scout.id)).toBe(false);
expect(within(dialog).getByText("Docs")).toBeDefined();
});

test("deletes an archived crew with its bots, then has nothing left", async () => {
const { fake, docs, scout, editor } = await someArchived();
await fake.call("bots.delete", { botId: scout.id });
const dialog = await openArchived();
fireEvent.click(await within(dialog).findByRole("button", { name: "Delete Docs" }));
const confirm = screen.getByRole("dialog", { name: "Delete Docs?" });
expect(confirm.textContent).toContain("Docs and its bot leave Botloft for good");
expect(within(confirm).getByText(docs.workFolder)).toBeDefined();
fireEvent.click(within(confirm).getByRole("button", { name: "Delete crew" }));
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();
expect(fake.crews.has(docs.id)).toBe(false);
expect(fake.bots.has(editor.id)).toBe(false);
});

test("says so when nothing is archived, or when the list cannot be read", async () => {
const fake = new FakeBotloft();
fake.addCrew("Ops");
renderApp(fake);
await crewOpened("Ops");
const dialog = await openArchived();
expect(await within(dialog).findByText("Nothing is archived.")).toBeDefined();

fake.failNext("archive.list", new RpcError(-32603, "internal error"));
fireEvent.click(within(dialog).getByRole("tab", { name: "About" }));
fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" }));
const alert = await within(dialog).findByRole("alert");
expect(alert.textContent).toContain("Could not load what is archived");
});
});
Loading
Loading