diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index c6818119..46bb51d6 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -494,3 +494,79 @@ test("both real sign and publish routes admit direct replies but reject arbitrar await h.close(); } }); + +test("lifecycle uses dedicated shape-limited host routes, never the message writer", async () => { + const h = await harness((call) => + Response.json( + call.url.endsWith("/events") + ? { accepted: true, event_id: call.body.id } + : [], + ), + ); + try { + const transport = await connectBrokerTransport(h.base); + expect(transport.writer.kinds).not.toContain(9008); + const id = "11111111-1111-4111-8111-111111111111"; + const template = { + kind: 9008, + tags: [["h", id]], + content: "", + created_at: 1700000000, + }; + expect((await h.post("sign", template)).status).toBe(400); + const invalid = [ + { + ...template, + kind: 9002, + tags: [ + ["h", id], + ["name", "rename"], + ], + }, + { + ...template, + kind: 9022, + tags: [ + ["h", id], + ["p", transport.viewer], + ], + }, + { ...template, content: "extra" }, + { + ...template, + tags: [ + ["h", id], + ["h", id], + ], + }, + ]; + for (const event of invalid) { + expect((await h.post("channel-lifecycle-sign", event)).status).toBe(400); + expect((await h.post("channel-lifecycle-publish", event)).status).toBe( + 400, + ); + } + const signal = new AbortController().signal; + const signed = await transport.channelLifecycle.sign(template, signal); + expect(verifyEvent(signed)).toBe(true); + expect(signed).toMatchObject(template); + expect((await h.post("publish", signed)).status).toBe(400); + await transport.channelLifecycle.publish(signed, signal); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 1, + ); + const foreignKey = new Uint8Array(32).fill(5); + const foreign = finalizeEvent( + { ...template, tags: template.tags.map((tag) => [...tag]) }, + foreignKey, + ); + expect((await h.post("channel-lifecycle-publish", foreign)).status).toBe( + 400, + ); + expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( + 1, + ); + } finally { + await h.close(); + } +}); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index 7d18a87f..946652c7 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,3 +1,4 @@ +import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts"; import { assertSidebarStarIntent, mutateSidebarStar, @@ -722,6 +723,7 @@ export function relayBrokerPlugin({ ...(await getAuthority(relay)), relayUrl: relay, writeKinds: [7, 9, ...WORKFLOW_KINDS], + channelLifecycle: true, workflowReads: true, sidebarPreferences: true, readState: true, @@ -932,6 +934,8 @@ export function relayBrokerPlugin({ ![ "/api/relay/query", "/api/relay/sign", + "/api/relay/channel-lifecycle-sign", + "/api/relay/channel-lifecycle-publish", "/api/relay/publish", "/api/relay/read-state-sign", "/api/relay/read-state-publish", @@ -1062,10 +1066,26 @@ export function relayBrokerPlugin({ sent: false, }); const timings = []; - const signing = route === "/api/relay/sign"; - const publishing = route === "/api/relay/publish"; + const lifecycle = + route === "/api/relay/channel-lifecycle-sign" || + route === "/api/relay/channel-lifecycle-publish"; + const signing = + route === "/api/relay/sign" || + route === "/api/relay/channel-lifecycle-sign"; + const publishing = + route === "/api/relay/publish" || + route === "/api/relay/channel-lifecycle-publish"; if (signing || publishing) { - if (![7, 9].includes(filters?.kind)) { + if (lifecycle) { + try { + validateLifecycleTemplate(filters); + } catch { + return json(res, 400, { + error: "Invalid channel lifecycle command", + sent: false, + }); + } + } else if (![7, 9].includes(filters?.kind)) { try { validateWorkflowEvent( { ...filters, pubkey: signing ? viewer : filters.pubkey }, diff --git a/docs/channels.md b/docs/channels.md index 574f538e..e4206e1d 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -101,6 +101,39 @@ groups are available; navigation history does not own them. The saved-groups browser regression records every visible return frame and holds the redundant decode path, so eventual restoration cannot conceal a fallback-group/scroll jump. +## Channel lifecycle + +The row menu resolves fresh relay-authored metadata (`39000`), administrators +(`39001`) and membership (`39002`) at exact channel coordinates before offering +Archive/Delete/Leave or DM Hide. Archive requires a direct owner/admin role; +Delete requires a direct owner role; the last owner cannot Leave. DMs offer Hide +only. Delegated owner-agent authority and community-admin overrides are not +inferred or supported by this slice; the relay remains the final authority. + +Each command has explicit confirmation; Delete additionally requires the channel +name. The lifecycle owner rechecks authority before signing and again before +publication, validates the returned command, and confirms relay-owned state before +removing a row. Archive retains membership; confirmed Delete/Leave use the existing +access-loss purge. Commands use narrow development-broker routes, never the message +outbox or automatic replay. Hosts without this capability display an unavailable +notice; native/direct-signer parity is deferred. + +DM Hide publishes `41012`, not Leave or Delete. The separate relay-authored `30622` +visibility snapshot (`d=viewer`, `p=viewer`, hidden DM `h` tags) only filters sidebar +rows; it does not deny access or prevent exact conversation navigation. Visibility +refreshes with the channel roster, preserves the last good set on failure and +rejects older snapshots. Live cross-device visibility updates and an in-app DM +reopen/unhide flow are deferred; opening a DM through another supported client's +`41010` flow and refreshing restores the row. + +A definitive rejection offers retry without optimistic removal. If publication or +confirmation has an uncertain outcome, the dialog warns that the command may have +taken effect, disables blind resubmission and asks the user to close and refresh +channels. Cancellation/cache clear/session replacement fence late results but cannot +retract a request already sent. Cancellation returns focus to the originating row; +confirmed removal moves an active conversation to another available destination +(or the neutral Messages page) with a sidebar/search focus fallback. + ## Performance and correctness carried from Astra The port retains the prepared-store implementation and its behavior tests: diff --git a/src/bundled/channels/ChannelLifecycleDialog.module.css b/src/bundled/channels/ChannelLifecycleDialog.module.css new file mode 100644 index 00000000..28f0dfe2 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.module.css @@ -0,0 +1,55 @@ +.dialog { + margin: auto; + color: var(--text-primary); + background: var(--bg-float); + border: 1px solid var(--border-primary); + border-radius: var(--radius-panel); + padding: var(--space-6); + width: min(480px, calc(100vw - 2 * var(--space-4))); + max-height: calc(100dvh - 2 * var(--space-4)); + overflow: auto; + box-shadow: var(--shadow-sm); + font-size: var(--text-body-sm); + line-height: var(--text-body-sm--line-height); + letter-spacing: var(--text-body-sm--letter-spacing); + font-weight: var(--type-weight-normal); +} +.dialog::backdrop { + background: var(--bg-scrim); +} +.dialog h2 { + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + letter-spacing: var(--text-heading--letter-spacing); + font-weight: var(--type-weight-medium); + margin: 0 0 var(--space-4); +} +.dialog p { + margin: var(--space-4) 0; +} +.dialog label { + display: grid; + gap: var(--space-2); +} +.dialog input { + width: 100%; + padding: var(--space-2) var(--space-3); + color: var(--text-primary); + background: var(--bg-inset); + border: 1px solid var(--border-primary); + border-radius: var(--radius-row); +} +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-6); +} +.actions [data-destructive]:not([data-disabled]) { + color: var(--red-12); + background: var(--red-3); +} +.actions [data-destructive]:hover:not([data-disabled]) { + background: var(--red-4); +} diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx new file mode 100644 index 00000000..bf5a6511 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from "react"; +import { Button } from "../../shared/design-system/ui/Button"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; +import styles from "./ChannelLifecycleDialog.module.css"; + +const copy = { + archive: { + title: "Archive channel", + detail: + "Archive this channel for everyone and remove it from the sidebar. Messages are retained. A channel administrator can unarchive it from another supported client.", + }, + delete: { + title: "Delete channel", + detail: + "Delete this channel for everyone. You cannot undo this action from Buzz.", + }, + leave: { + title: "Leave channel", + detail: + "Leave this channel and remove it from your sidebar. You may need an invitation to rejoin a private channel.", + }, + hide: { + title: "Hide conversation", + detail: + "Hide this conversation from your sidebar only. Messages and membership are kept; other participants are not removed.", + }, +} as const; + +export function ChannelLifecycleDialog({ + channelId, + channelName, + action, + lifecycle, + close, + completed, +}: { + channelId: string; + channelName: string; + action: ChannelLifecycleAction; + lifecycle: ChannelLifecycleCapability; + close(): void; + completed(): void; +}) { + const dialog = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [refreshRequired, setRefreshRequired] = useState(false); + const [confirmation, setConfirmation] = useState(""); + const operation = useRef(undefined); + useEffect(() => { + dialog.current?.showModal(); + return () => { + operation.current?.abort(); + }; + }, []); + const submit = async () => { + if ( + operation.current || + refreshRequired || + (action === "delete" && confirmation !== channelName) + ) + return; + const controller = new AbortController(); + operation.current = controller; + setBusy(true); + setError(""); + try { + await lifecycle.run(action, channelId, controller.signal); + if (!controller.signal.aborted) completed(); + } catch (error) { + if (!controller.signal.aborted) { + setError(error instanceof Error ? error.message : String(error)); + setRefreshRequired(error instanceof ChannelLifecycleUnconfirmed); + } + } finally { + operation.current = undefined; + if (!controller.signal.aborted) setBusy(false); + } + }; + return ( + { + event.preventDefault(); + if (!busy) close(); + }} + > +

+ {copy[action].title}: {channelName} +

+

{copy[action].detail}

+ {action === "delete" && ( + + )} + {error &&

{error}

} + {busy && ( +

+ Checking permissions and waiting for relay confirmation… +

+ )} +
+ + +
+
+ ); +} diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx new file mode 100644 index 00000000..87cd4a2e --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -0,0 +1,228 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ContextMenuRoot, MenuPopup } from "../../shared/design-system/ui/Menu"; +import { + ChannelLifecycleUnconfirmed, + type ChannelLifecycleCapability, +} from "../../features/relay/channel-lifecycle"; +import { ChannelLifecycleMenu } from "./ChannelLifecycleMenu"; +import { ChannelLifecycleDialog } from "./ChannelLifecycleDialog"; +import type { ChannelLifecycleSettings } from "../../features/relay/channel-lifecycle-protocol"; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const settings: ChannelLifecycleSettings = { + channelId: "id", + channelType: "stream", + canArchive: true, + canDelete: true, + canLeave: false, + canHide: false, + leaveReason: "Transfer ownership before leaving the channel.", +}; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function capability() { + return { + available: true, + load: vi.fn(async () => settings), + run: vi.fn(async () => {}), + snapshot: () => ({ status: "ready", hidden: [] }), + subscribe: () => () => {}, + refreshVisibility: async () => {}, + } satisfies ChannelLifecycleCapability; +} +it("shows fresh loading and the last-owner boundary", async () => { + const user = userEvent.setup(); + const lifecycle = capability(); + const choose = vi.fn(); + const gate = deferred(); + lifecycle.load.mockReturnValueOnce(gate.promise); + render( + + + + + , + ); + expect( + await screen.findByText("Checking channel permissions…"), + ).toBeDefined(); + expect(screen.queryByText("Delete channel…")).toBeNull(); + gate.resolve(settings); + const leave = await screen.findByRole("menuitem", { name: "Leave channel…" }); + expect(leave.getAttribute("aria-disabled")).toBe("true"); + await user.click(screen.getByRole("menuitem", { name: "Delete channel…" })); + expect(choose).toHaveBeenCalledWith("delete"); +}); +it("failed permission reads offer retry rather than stale destructive actions", async () => { + const user = userEvent.setup(); + const lifecycle = capability(); + lifecycle.load.mockRejectedValueOnce(new Error("permissions offline")); + render( + + + {}} + disabled={false} + /> + + , + ); + expect((await screen.findByRole("alert")).textContent).toBe( + "permissions offline", + ); + expect( + screen.queryByRole("menuitem", { name: "Archive channel…" }), + ).toBeNull(); + await user.click( + screen.getByRole("menuitem", { name: "Retry channel permissions" }), + ); + expect( + await screen.findByRole("menuitem", { name: "Archive channel…" }), + ).toBeDefined(); +}); +it("confirmation, pending lockout and failed-write recovery stay in the actual dialog", async () => { + // jsdom does not implement top-layer focus; that contract is covered in browsers. + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const user = userEvent.setup(); + const lifecycle = capability(); + const completed = vi.fn(); + const close = vi.fn(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => + gate.promise.then(() => { + throw new Error("relay rejected"); + }), + ); + render( + , + ); + const confirm = screen.getByRole("button", { + name: "Delete channel", + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + await user.type( + screen.getByRole("textbox", { name: "Channel name confirmation" }), + "Fixture", + ); + await user.click(confirm); + await waitFor(() => expect(lifecycle.run).toHaveBeenCalledOnce()); + expect(confirm.disabled).toBe(true); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + gate.resolve(); + expect((await screen.findByRole("alert")).textContent).toBe("relay rejected"); + expect(completed).not.toHaveBeenCalled(); + expect(confirm.disabled).toBe(false); + await user.click(confirm); + await waitFor(() => expect(completed).toHaveBeenCalledOnce()); +}); + +it.each(["leave", "hide", "archive"] as const)( + "%s requires confirmation and ignores completion after unmount", + async (action) => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => gate.promise); + const completed = vi.fn(); + const user = userEvent.setup(); + const view = render( + {}} + completed={completed} + />, + ); + expect(lifecycle.run).not.toHaveBeenCalled(); + const label = { + leave: "Leave channel", + hide: "Hide conversation", + archive: "Archive channel", + }[action]; + await user.click(screen.getByRole("button", { name: label })); + expect(lifecycle.run).toHaveBeenCalledWith( + action, + "id", + expect.any(AbortSignal), + ); + const signal = vi.mocked(lifecycle.run).mock.calls[0]?.[2]; + view.unmount(); + expect(signal?.aborted).toBe(true); + gate.resolve(); + await gate.promise; + expect(completed).not.toHaveBeenCalled(); + }, +); +it("uncertain delivery keeps the dialog recoverable without offering blind resubmission", async () => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + lifecycle.run.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed("connection lost"), + ); + const user = userEvent.setup(); + const close = vi.fn(); + render( + {}} + />, + ); + const confirm = screen.getByRole("button", { + name: "Leave channel", + }) as HTMLButtonElement; + await user.click(confirm); + expect((await screen.findByRole("alert")).textContent).toContain( + "may have taken effect", + ); + expect(confirm.disabled).toBe(true); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(lifecycle.run).toHaveBeenCalledOnce(); +}); diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx new file mode 100644 index 00000000..0def0bb4 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -0,0 +1,91 @@ +import { useEffect, useState } from "react"; +import { MenuItem } from "../../shared/design-system/ui/Menu"; +import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle"; +import type { + ChannelLifecycleAction, + ChannelLifecycleSettings, +} from "../../features/relay/channel-lifecycle-protocol"; + +/** Mounted only while a menu is open: no per-row/background capability reads. */ +export function ChannelLifecycleMenu({ + channelId, + lifecycle, + choose, + disabled, +}: { + channelId: string; + lifecycle: ChannelLifecycleCapability; + choose(action: ChannelLifecycleAction): void; + disabled: boolean; +}) { + const [state, setState] = useState(); + const [error, setError] = useState(""); + const [retry, setRetry] = useState(0); + // biome-ignore lint/correctness/useExhaustiveDependencies: explicit retry starts a fresh permission lookup. + useEffect(() => { + if (!lifecycle.available) return; + const controller = new AbortController(); + setState(undefined); + setError(""); + void lifecycle.load(channelId, controller.signal).then( + (settings) => { + if (!controller.signal.aborted) setState(settings); + }, + (error: unknown) => { + if (!controller.signal.aborted) + setError(error instanceof Error ? error.message : String(error)); + }, + ); + return () => controller.abort(); + }, [channelId, lifecycle, retry]); + if (!lifecycle.available) + return ( + + Channel actions unavailable on this connection + + ); + if (error) + return ( + <> +

{error}

+ setRetry((value) => value + 1)} + > + Retry channel permissions + + + ); + if (!state) + return Checking channel permissions…; + return ( + <> + {state.canHide ? ( + choose("hide")}> + Hide conversation… + + ) : ( + <> + {state.canArchive && ( + choose("archive")}> + Archive channel… + + )} + {state.canDelete && ( + choose("delete")}> + Delete channel… + + )} + choose("leave")} + > + Leave channel… + + {state.leaveReason &&

{state.leaveReason}

} + + )} + + ); +} diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index 1bfb9b82..3777d037 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,3 +1,6 @@ +import { ChannelLifecycleMenu } from "./ChannelLifecycleMenu"; +import { ChannelLifecycleDialog } from "./ChannelLifecycleDialog"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { useChannelPanels } from "./useChannelPanels"; import type { PageNavigation } from "../../features/navigation/service"; import type { Navigation } from "../../features/navigation/controller"; @@ -158,6 +161,21 @@ function ChannelWorkspace({ }) { const list = useChannelList(queries.channels); const preferences = useSidebarPreferences(queries.sidebarPreferences); + const lifecycle = queries.channelLifecycle; + const dmVisibility = useSyncExternalStore( + lifecycle.subscribe, + lifecycle.snapshot, + lifecycle.snapshot, + ); + // biome-ignore lint/correctness/useExhaustiveDependencies: a completed roster refresh also refreshes per-viewer visibility. + useEffect(() => { + if (list.status === "ready") void lifecycle.refreshVisibility(); + }, [lifecycle, list.asOf, list.status]); + const [lifecycleDialog, setLifecycleDialog] = useState<{ + channel: ChannelSummary; + action: ChannelLifecycleAction; + }>(); + const lifecycleFocus = useRef(undefined); useEffect(() => { if (list.status === "ready") void queries.unread.ensure(); }, [queries, list.status]); @@ -213,13 +231,42 @@ function ChannelWorkspace({ list.status === "ready" && preferences.status !== "loading", ); const { search } = sidebar; - const channels = useChannelLabels(list.channels, queries.profiles); + const labelled = useChannelLabels(list.channels, queries.profiles); + const channels = useMemo( + () => + labelled.filter( + (channel) => + !channel.archived && + (channel.channelType !== "dm" || + !dmVisibility.hidden.includes(channel.id)), + ), + [labelled, dmVisibility.hidden], + ); + useLayoutEffect(() => { + if (!lifecycleFocus.current || lifecycleDialog) return; + const id = lifecycleFocus.current; + lifecycleFocus.current = undefined; + const origin = sidebar.list.current?.querySelector( + `[data-channel-id="${CSS.escape(id)}"]`, + ); + const fallback = + sidebar.list.current?.querySelector( + "[data-channel-id]", + ); + // Hidden/collapsed sections have no focusable row; leave an accessible fallback. + const target = origin?.getClientRects().length ? origin : fallback; + if (target?.getClientRects().length) target.focus({ preventScroll: true }); + else + sidebar.list.current?.closest("aside")?.querySelector("input")?.focus(); + }, [lifecycleDialog, sidebar.list]); const requestedChannel = navigation?.target.kind === "conversation" ? navigation.target.channelId : undefined; const current = requestedChannel - ? (channels.find((channel) => channel.id === requestedChannel) ?? + ? (labelled.find( + (channel) => channel.id === requestedChannel && !channel.archived, + ) ?? (list.coverage === "partial" ? { id: requestedChannel, name: "Conversation" } : undefined)) @@ -577,6 +624,43 @@ function ChannelWorkspace({
+ {lifecycleDialog && ( + { + lifecycleFocus.current = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + }} + completed={() => { + lifecycleFocus.current = lifecycleDialog.channel.id; + const id = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + if ( + current?.id === id || + requestedChannel === id || + selected === id + ) { + const next = channels.find( + (channel) => channel.id !== id && !channel.archived, + ); + if (next) select(next.id); + else { + setSelected(undefined); + writeView(scope, "selected-channel", undefined); + void navigator?.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); + } + } + }} + /> + )}