From 4e1b075ec8e8d5d63647d1bccb4c30643e45acf4 Mon Sep 17 00:00:00 2001 From: Carl Date: Thu, 17 Sep 2026 09:00:01 -1000 Subject: [PATCH 01/11] feat(channels): confirm archive delete leave and dm hide actions Resolve fresh relay-owned channel permissions, keep lifecycle commands outside the message outbox, and confirm authoritative state before removing rows. Preserve DM membership, fence cancelled sessions, and make ambiguous publication recoverable without blind resubmission. Signed-off-by: Carl --- dev/relay-broker-api.test.mjs | 76 +++ dev/relay-broker.mjs | 26 +- docs/channels.md | 33 ++ .../ChannelLifecycleDialog.module.css | 55 ++ .../channels/ChannelLifecycleDialog.tsx | 137 +++++ .../channels/ChannelLifecycleMenu.test.tsx | 228 ++++++++ src/bundled/channels/ChannelLifecycleMenu.tsx | 91 ++++ .../channel-navigation/ChannelSidebar.tsx | 86 ++- .../relay/channel-lifecycle-protocol.ts | 154 ++++++ src/features/relay/channel-lifecycle.test.ts | 497 ++++++++++++++++++ src/features/relay/channel-lifecycle.ts | 308 +++++++++++ src/features/relay/session.ts | 15 + src/features/relay/transport.ts | 37 ++ tests/browser/channel-lifecycle.spec.mjs | 200 +++++++ tests/browser/fixture.mjs | 127 ++++- tests/browser/policy-relay.mjs | 26 + 16 files changed, 2072 insertions(+), 24 deletions(-) create mode 100644 src/bundled/channels/ChannelLifecycleDialog.module.css create mode 100644 src/bundled/channels/ChannelLifecycleDialog.tsx create mode 100644 src/bundled/channels/ChannelLifecycleMenu.test.tsx create mode 100644 src/bundled/channels/ChannelLifecycleMenu.tsx create mode 100644 src/features/relay/channel-lifecycle-protocol.ts create mode 100644 src/features/relay/channel-lifecycle.test.ts create mode 100644 src/features/relay/channel-lifecycle.ts create mode 100644 tests/browser/channel-lifecycle.spec.mjs diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 82b067504..53f4c7ce8 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1830,3 +1830,79 @@ test("relay quota on admin routes stays a quota failure, not a refusal", async ( 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 263a54bdf..1cf7accdc 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -7,6 +7,7 @@ import { import { prepareMedia } from "./media-preparation.mjs"; import { readProjectGit } from "./project-git.mjs"; import { parseGitRead } from "../src/features/projects/git.ts"; +import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts"; import { prepareChannelKit, decodeChannelKit, @@ -896,6 +897,7 @@ export function relayBrokerPlugin({ ...WORKFLOW_KINDS, ...((await getAuthority(relay)).channelCreation ? [9007] : []), ], + channelLifecycle: true, workflowReads: true, projectGit: true, attachmentUploads: true, @@ -1353,6 +1355,8 @@ export function relayBrokerPlugin({ "/api/relay/agent-memories", "/api/relay/presence-snapshot", "/api/relay/sign", + "/api/relay/channel-lifecycle-sign", + "/api/relay/channel-lifecycle-publish", "/api/relay/publish", "/api/relay/read-state-sign", "/api/relay/channel-kit-prepare", @@ -1611,10 +1615,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 ([9000, 9007].includes(filters?.kind)) { + if (lifecycle) { + try { + validateLifecycleTemplate(filters); + } catch { + return json(res, 400, { + error: "Invalid channel lifecycle command", + sent: false, + }); + } + } else if ([9000, 9007].includes(filters?.kind)) { const enrollment = validAgentEnrollment(filters); const authority = await getAuthority(relay); if ( diff --git a/docs/channels.md b/docs/channels.md index 240e4bd93..ab01e5984 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -225,6 +225,39 @@ Focused coverage lives in `NewMessage.test.tsx`, `direct-messages.test.ts`, The browser journey uses the production app and broker with ephemeral identities and modeled upstream I/O; it does not send messages to a live community. +## 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 000000000..28f0dfe2a --- /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 000000000..bf5a65112 --- /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 000000000..87cd4a2e2 --- /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 000000000..0def0bb4f --- /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/features/channel-navigation/ChannelSidebar.tsx b/src/features/channel-navigation/ChannelSidebar.tsx index 7b8224557..8bacc8f80 100644 --- a/src/features/channel-navigation/ChannelSidebar.tsx +++ b/src/features/channel-navigation/ChannelSidebar.tsx @@ -9,6 +9,9 @@ import { useSyncExternalStore, type ReactNode, } from "react"; +import { ChannelLifecycleDialog } from "../../bundled/channels/ChannelLifecycleDialog"; +import { ChannelLifecycleMenu } from "../../bundled/channels/ChannelLifecycleMenu"; +import type { ChannelLifecycleAction } from "../relay/channel-lifecycle-protocol"; import { personalGroups } from "../channel-templates/setup"; import type { TemplateProviders } from "../channel-templates/provider"; import type { RelayData } from "../relay/service"; @@ -158,6 +161,21 @@ function ReadySidebar({ const personal = personalGroups(kitState.entries)?.record.value; const groups = personal?.type === "groups" ? personal : undefined; const hiddenDms = useHiddenDms(scope, queries, list); + 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); const sidebar = useSidebarView( scope, list.status === "ready" && preferences.status !== "loading", @@ -191,11 +209,12 @@ function ReadySidebar({ const preparingDm = composingMessage ? handoff?.preparingDm : undefined; const sidebarChannels = channels.filter( (channel) => + !dmVisibility.hiddenIds.includes(channel.id) && ( !preparingDm || preparingDm.existing.has(channel.id) || channel.channelType !== "dm" || channel.members?.length !== preparingDm.members.size || - !channel.members.every((member) => preparingDm.members.has(member)), + !channel.members.every((member) => preparingDm.members.has(member))), ); const current = channels.find( (channel) => @@ -250,6 +269,21 @@ function ReadySidebar({ mounted.current = false; }; }, []); + const chooseLifecycle = (channel: ChannelSummary, action: ChannelLifecycleAction) => { + // Let the existing context menu restore focus before opening confirmation. + requestAnimationFrame(() => { + if (mounted.current) setLifecycleDialog({ channel, action }); + }); + }; + useLayoutEffect(() => { + if (!lifecycleFocus.current || lifecycleDialog) return; + const id = lifecycleFocus.current; + lifecycleFocus.current = undefined; + const rows = [...(sidebar.list.current?.querySelectorAll("[data-channel-id]") ?? [])]; + const row = rows.find((row) => row.dataset.channelId === id && row.getClientRects().length) + ?? rows.find((row) => row.getClientRects().length); + (row ?? sidebar.list.current?.closest("aside")?.querySelector("button"))?.focus({ preventScroll: true }); + }, [lifecycleDialog, sidebar.list]); const select = useCallback( (id: string) => { if (!viewer || relay.snapshot().session !== queries) return; @@ -390,6 +424,18 @@ function ReadySidebar({ run={(action) => runReadAction(channel.id, action)} />, ); + if (channel.channelType !== "session" && !channel.archived) { + actions.push( + 0} + channelId={channel.id} + lifecycle={lifecycle} + disabled={!!lifecycleDialog} + choose={(action) => chooseLifecycle(channel, action)} + />, + ); + } return actions; }; const { @@ -447,6 +493,36 @@ function ReadySidebar({ }; return ( <> + {lifecycleDialog && ( + { + lifecycleFocus.current = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + }} + completed={() => { + const id = lifecycleDialog.channel.id; + lifecycleFocus.current = id; + setLifecycleDialog(undefined); + if (current?.id === id) { + const next = sections.flatMap((section) => section.rows).find((channel) => channel.id !== id); + if (next) select(next.id); + else { + writeView(scope, "selected-channel", undefined); + void navigator.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); + } + } + }} + /> + )}
@@ -500,6 +576,14 @@ function ReadySidebar({ )}
)} + {dmVisibility.status === "error" && ( +
+ Hidden conversations could not be refreshed.{" "} + +
+ )} {sections.map((section) => { const showsCreateChannel = isChannelSectionKey(section.key); diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts new file mode 100644 index 000000000..70daf8d25 --- /dev/null +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -0,0 +1,154 @@ +import type { EventTemplate } from "nostr-tools"; +import { newer, type RelayEvent } from "./events.ts"; + +export type ChannelLifecycleAction = "archive" | "delete" | "leave" | "hide"; +export const CHANNEL_LIFECYCLE_KINDS = [9002, 9008, 9022, 41012] as const; +export const DM_VISIBILITY_KIND = 30622; +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; +const PUBKEY = /^[0-9a-f]{64}$/; + +export function lifecycleChannelId(value: string): string { + if (typeof value !== "string" || !UUID.test(value)) + throw new Error("Invalid channel ID"); + return value; +} + +export function lifecycleTemplate( + action: ChannelLifecycleAction, + channelId: string, +): EventTemplate { + const kind = { archive: 9002, delete: 9008, leave: 9022, hide: 41012 }[ + action + ]; + const event = { + kind, + created_at: Math.floor(Date.now() / 1000), + content: "", + tags: [ + ["h", lifecycleChannelId(channelId)], + ...(action === "archive" ? [["archived", "true"]] : []), + ], + }; + validateLifecycleTemplate(event); + return event; +} + +/** The host never accepts generic metadata edits or other-member removal. */ +export function validateLifecycleTemplate(event: EventTemplate): void { + if ( + !event || + !CHANNEL_LIFECYCLE_KINDS.some((kind) => kind === event.kind) || + event.content !== "" || + !Number.isSafeInteger(event.created_at) || + event.created_at < 0 || + !Array.isArray(event.tags) || + event.tags.length !== (event.kind === 9002 ? 2 : 1) || + event.tags[0]?.length !== 2 || + event.tags[0]?.[0] !== "h" + ) + throw new Error("Invalid channel lifecycle command"); + lifecycleChannelId(event.tags[0]?.[1] ?? ""); + if ( + event.kind === 9002 && + JSON.stringify(event.tags[1]) !== JSON.stringify(["archived", "true"]) + ) + throw new Error("Only archive metadata may be changed here"); +} + +export function exactLifecycleTag(event: RelayEvent, name: string) { + const matches = event.tags.filter(([key]) => key === name); + if (matches.length > 1 || matches.some((entry) => entry.length !== 2)) + throw new Error(`Malformed channel ${name} state`); + return matches[0]?.[1]; +} + +export function lifecycleRecord( + events: readonly RelayEvent[], + kind: number, + id: string, + relayAuthor: string, +) { + let selected: RelayEvent | undefined; + for (const event of events) { + if (event.kind !== kind) continue; + if (event.pubkey !== relayAuthor || exactLifecycleTag(event, "d") !== id) + throw new Error("Channel state did not match the requested authority"); + selected = newer(selected, event); + } + return selected; +} + +export type ChannelLifecycleSettings = Readonly<{ + channelId: string; + channelType: "stream" | "forum" | "dm"; + canArchive: boolean; + canDelete: boolean; + canLeave: boolean; + canHide: boolean; + leaveReason?: string; +}>; + +export function lifecycleSettings( + events: readonly RelayEvent[], + id: string, + viewer: string, + relayAuthor: string, +): ChannelLifecycleSettings { + const metadata = lifecycleRecord(events, 39000, id, relayAuthor); + const admins = lifecycleRecord(events, 39001, id, relayAuthor); + const roster = lifecycleRecord(events, 39002, id, relayAuthor); + if (!metadata || !admins || !roster) + throw new Error("Current channel permissions could not be verified"); + const type = exactLifecycleTag(metadata, "t"); + if (type !== "stream" && type !== "forum" && type !== "dm") + throw new Error("Current channel type could not be verified"); + const members = new Set(); + for (const entry of roster.tags.filter(([key]) => key === "p")) { + const member = entry[1]; + if ( + entry.length !== 2 || + !member || + !PUBKEY.test(member) || + members.has(member) + ) + throw new Error("Malformed channel membership state"); + members.add(member); + } + const roles = new Map(); + for (const entry of admins.tags.filter(([key]) => key === "p")) { + const [, pubkey, role] = entry; + if ( + entry.length !== 3 || + !pubkey || + !PUBKEY.test(pubkey) || + (role !== "owner" && role !== "admin") || + roles.has(pubkey) || + !members.has(pubkey) + ) + throw new Error("Malformed channel administrator state"); + roles.set(pubkey, role); + } + if (!members.has(viewer)) + throw new Error("You are no longer a channel member"); + const role = roles.get(viewer); + const lastOwner = + role === "owner" && + [...roles.values()].filter((value) => value === "owner").length === 1; + const archived = exactLifecycleTag(metadata, "archived"); + if (archived !== undefined && archived !== "true" && archived !== "false") + throw new Error("Malformed channel archive state"); + return Object.freeze({ + channelId: id, + channelType: type, + canArchive: + type !== "dm" && + archived !== "true" && + (role === "owner" || role === "admin"), + canDelete: type !== "dm" && role === "owner", + canLeave: type !== "dm" && !lastOwner, + canHide: type === "dm", + ...(lastOwner && type !== "dm" + ? { leaveReason: "Transfer ownership before leaving the channel." } + : {}), + }); +} diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts new file mode 100644 index 000000000..3892cc8d0 --- /dev/null +++ b/src/features/relay/channel-lifecycle.test.ts @@ -0,0 +1,497 @@ +import { describe, expect, it, vi } from "vitest"; +import { finalizeEvent, getPublicKey, type EventTemplate } from "nostr-tools"; +import { PublishRejected } from "./outbox"; +import { + createChannelLifecycle, + ChannelLifecycleUnconfirmed, +} from "./channel-lifecycle"; +import { + lifecycleSettings, + lifecycleTemplate, + validateLifecycleTemplate, +} from "./channel-lifecycle-protocol"; +import type { ReadFilter, RelayEvent } from "./events"; + +const key = new Uint8Array(32).fill(2); +const relayKey = new Uint8Array(32).fill(3); +const other = getPublicKey(new Uint8Array(32).fill(4)); +const viewer = getPublicKey(key); +const relayAuthor = getPublicKey(relayKey); +const id = "11111111-1111-4111-8111-111111111111"; +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((done) => { + resolve = done; + }); + return { promise, resolve }; +} +function harness(role = "owner", type = "stream", owners = 1) { + let timestamp = 100; + const record = (kind: number, tags: string[][], content = "") => + finalizeEvent({ kind, tags, content, created_at: timestamp++ }, relayKey); + const metadata = (archived = false) => + record(39000, [ + ["d", id], + ["t", type], + ["name", "fixture"], + ...(archived ? [["archived", "true"]] : []), + ]); + const roles = (role: string) => + record(39001, [ + ["d", id], + ...(role === "member" ? [] : [["p", viewer, role]]), + ...(owners > 1 ? [["p", other, "owner"]] : []), + ]); + let events = [ + metadata(), + roles(role), + record(39002, [ + ["d", id], + ["p", viewer], + ["p", other], + ]), + ]; + let visible: RelayEvent[] = []; + let active = true; + const read = vi.fn(async (filters: readonly ReadFilter[]) => { + if (filters[0]?.kinds?.[0] === 30622) return visible; + return events.filter((event) => + filters.some((filter) => filter.kinds?.includes(event.kind)), + ); + }); + const sign = vi.fn(async (event: EventTemplate) => + finalizeEvent(structuredClone(event), key), + ); + const publish = vi.fn(async (event: RelayEvent) => { + if (event.kind === 9002) + events = [ + metadata(true), + ...events.filter((event) => event.kind !== 39000), + ]; + if (event.kind === 9008) + events = events.filter((event) => event.kind !== 39000); + if (event.kind === 9022) + events = events.filter((event) => event.kind !== 39002); + if (event.kind === 41012) + visible = [ + record(30622, [ + ["d", viewer], + ["p", viewer], + ["h", id], + ]), + ]; + }); + const removed = vi.fn(); + const acceptDiscovery = vi.fn(); + const owner = createChannelLifecycle({ + reader: { read }, + writer: { sign, publish }, + viewer, + relayAuthor, + canAccess: () => active, + removed, + acceptDiscovery, + }); + return { + owner, + read, + sign, + publish, + removed, + acceptDiscovery, + record, + roles, + metadata, + setEvents: (value: RelayEvent[]) => { + events = value; + }, + getEvents: () => events, + setVisible: (value: RelayEvent[]) => { + visible = value; + }, + deny: () => { + active = false; + }, + }; +} + +describe("type and role boundaries", () => { + it.each([ + ["owner", "stream", 1, true, true, false, false], + ["owner", "stream", 2, true, true, true, false], + ["admin", "forum", 1, true, false, true, false], + ["member", "stream", 1, false, false, true, false], + ["owner", "dm", 1, false, false, false, true], + ] as const)( + "%s %s with %s owner(s)", + async (role, type, owners, canArchive, canDelete, canLeave, canHide) => { + const h = harness(role, type, owners); + expect(await h.owner.capability.load(id)).toMatchObject({ + canArchive, + canDelete, + canLeave, + canHide, + }); + h.owner.dispose(); + }, + ); + it("validates the complete administrator record, not just the viewer's first match", () => { + const h = harness(); + h.setEvents([ + h.metadata(), + h.record(39001, [ + ["d", id], + ["p", viewer, "owner"], + ["p", other, "bogus"], + ]), + h.record(39002, [ + ["d", id], + ["p", viewer], + ["p", other], + ]), + ]); + expect(() => + lifecycleSettings(h.getEvents(), id, viewer, relayAuthor), + ).toThrow("Malformed"); + }); + it.each(["archive", "delete", "leave"] as const)( + "cannot %s a DM", + async (action) => { + const h = harness("owner", "dm"); + await expect(h.owner.capability.run(action, id)).rejects.toThrow( + "no longer permitted", + ); + expect(h.sign).not.toHaveBeenCalled(); + h.owner.dispose(); + }, + ); + it("requires a complete trusted metadata/admin/member response", async () => { + const h = harness(); + h.setEvents(h.getEvents().slice(0, 2)); + await expect(h.owner.capability.load(id)).rejects.toThrow( + "could not be verified", + ); + h.owner.dispose(); + }); + it("rejects a foreign author even with matching coordinates", async () => { + const h = harness(); + h.setEvents([ + finalizeEvent( + { + kind: 39000, + tags: [ + ["d", id], + ["t", "stream"], + ], + content: "", + created_at: 1000, + }, + key, + ), + ...h.getEvents().slice(1), + ]); + await expect(h.owner.capability.load(id)).rejects.toThrow("authority"); + h.owner.dispose(); + }); +}); + +it.each(["archive", "delete", "leave", "hide"] as const)( + "confirms %s without granting it to the message outbox", + async (action) => { + const clock = vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000); + try { + const h = harness("owner", action === "hide" ? "dm" : "stream", 2); + await h.owner.capability.run(action, id); + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.sign.mock.calls[0]?.[0]).toEqual(lifecycleTemplate(action, id)); + if (action === "archive") + expect(h.acceptDiscovery).toHaveBeenCalledOnce(); + else if (action !== "hide") expect(h.removed).toHaveBeenCalledWith(id); + else { + expect(h.owner.capability.snapshot().hidden).toEqual([id]); + expect(h.removed).not.toHaveBeenCalled(); + } + h.owner.dispose(); + } finally { + clock.mockRestore(); + } + }, +); + +it("rereads authority after signing; a removed admin cannot publish", async () => { + const h = harness("admin"); + h.sign.mockImplementation(async (event) => { + h.setEvents([ + h.metadata(), + h.roles("member"), + h.record(39002, [ + ["d", id], + ["p", viewer], + ["p", other], + ]), + ]); + return finalizeEvent(event, key); + }); + await expect(h.owner.capability.run("archive", id)).rejects.toThrow( + "no longer permitted", + ); + expect(h.publish).not.toHaveBeenCalled(); + h.owner.dispose(); +}); +it("fences access loss during signing and rejects a changed timestamp", async () => { + const h = harness(); + h.sign.mockImplementationOnce(async (event) => { + h.deny(); + return finalizeEvent(event, key); + }); + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "access unavailable", + ); + expect(h.publish).not.toHaveBeenCalled(); + h.owner.dispose(); + const g = harness(); + g.sign.mockImplementationOnce(async (event) => + finalizeEvent({ ...event, created_at: event.created_at + 1 }, key), + ); + await expect(g.owner.capability.run("delete", id)).rejects.toThrow( + "Signer changed", + ); + expect(g.publish).not.toHaveBeenCalled(); + g.owner.dispose(); +}); +it.each(["clear", "dispose", "cancel"] as const)( + "%s stops an in-flight signer even when it ignores abort", + async (action) => { + const h = harness(); + const gate = deferred(); + const started = deferred(); + h.sign.mockImplementationOnce(async (event) => { + started.resolve(); + await gate.promise; + return finalizeEvent(event, key); + }); + const run = h.owner.capability.run("delete", id); + await started.promise; + h.owner[action](); + gate.resolve(); + await expect(run).rejects.toThrow(); + expect(h.publish).not.toHaveBeenCalled(); + h.owner.dispose(); + }, +); +it("does not infer deletion from a failed confirmation read; retry can recover", async () => { + const h = harness(); + h.publish.mockImplementationOnce(async () => { + h.read.mockRejectedValueOnce(new Error("query failed")); + }); + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "query failed", + ); + expect(h.removed).not.toHaveBeenCalled(); + await h.owner.capability.run("delete", id); + expect(h.removed).toHaveBeenCalledOnce(); + h.owner.dispose(); +}); +it("serializes destructive operations without automatic resubmission", async () => { + const h = harness(); + const gate = deferred(); + const started = deferred(); + h.sign.mockImplementationOnce(async (event) => { + started.resolve(); + await gate.promise; + return finalizeEvent(event, key); + }); + const run = h.owner.capability.run("delete", id); + await started.promise; + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "in progress", + ); + gate.resolve(); + await run; + expect(h.publish).toHaveBeenCalledOnce(); + h.owner.dispose(); +}); +it("retains the last good visibility on failure and rejects stale snapshots", async () => { + const h = harness("owner", "dm"); + const stale = h.record(30622, [ + ["d", viewer], + ["p", viewer], + ]); + await h.owner.capability.run("hide", id); + h.setVisible([stale]); + await h.owner.capability.refreshVisibility(); + expect(h.owner.capability.snapshot().hidden).toEqual([id]); + h.read.mockRejectedValueOnce(new Error("offline")); + await h.owner.capability.refreshVisibility(); + expect(h.owner.capability.snapshot()).toMatchObject({ + status: "error", + hidden: [id], + }); + h.setVisible([ + h.record(30622, [ + ["d", viewer], + ["p", viewer], + ]), + ]); + await h.owner.capability.refreshVisibility(); + expect(h.owner.capability.snapshot()).toMatchObject({ + status: "ready", + hidden: [], + }); + h.owner.dispose(); +}); +it("a clear cannot be repopulated by a late visibility response", async () => { + const h = harness(); + const gate = deferred(); + h.read.mockImplementationOnce(() => gate.promise); + const pending = h.owner.capability.refreshVisibility(); + h.owner.clear(); + gate.resolve([ + h.record(30622, [ + ["d", viewer], + ["p", viewer], + ["h", id], + ]), + ]); + await pending; + expect(h.owner.capability.snapshot()).toMatchObject({ + status: "idle", + hidden: [], + }); + h.owner.dispose(); +}); +it.each([ + { + kind: 9002, + tags: [ + ["h", id], + ["name", "rename"], + ], + }, + { + kind: 9022, + tags: [ + ["h", id], + ["p", other], + ], + }, + { + kind: 9008, + tags: [ + ["h", id], + ["h", id], + ], + }, + { kind: 41012, tags: [["h", "not-a-channel"]] }, + { kind: 9, tags: [["h", id]] }, +])("rejects expanded host authority %#", (changes) => { + expect(() => + validateLifecycleTemplate({ content: "", created_at: 1, ...changes }), + ).toThrow(); +}); +it("a signer cannot mutate the input in place to bypass command equality", async () => { + const h = harness(); + h.sign.mockImplementationOnce(async (event) => { + event.kind = 9022; + return finalizeEvent(event, key); + }); + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "Signer changed", + ); + expect(h.publish).not.toHaveBeenCalled(); + h.owner.dispose(); +}); + +it("distinguishes a rejected publication from an uncertain delivery without replay", async () => { + const h = harness(); + h.publish.mockRejectedValueOnce(new PublishRejected("permission revoked")); + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "permission revoked", + ); + expect(h.removed).not.toHaveBeenCalled(); + h.publish.mockRejectedValueOnce(new Error("connection lost")); + await expect(h.owner.capability.run("delete", id)).rejects.toBeInstanceOf( + ChannelLifecycleUnconfirmed, + ); + expect(h.publish).toHaveBeenCalledTimes(2); + expect(h.removed).not.toHaveBeenCalled(); + h.owner.dispose(); +}); +it("an accepted command without side effects remains uncertain, never replayed", async () => { + vi.useFakeTimers(); + const h = harness(); + try { + h.publish.mockImplementationOnce(async () => {}); + const run = expect( + h.owner.capability.run("delete", id), + ).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + await vi.runAllTimersAsync(); + await run; + expect(h.publish).toHaveBeenCalledOnce(); + expect(h.removed).not.toHaveBeenCalled(); + } finally { + h.owner.dispose(); + vi.useRealTimers(); + } +}); +it("session replacement during publication cannot apply a late completion", async () => { + const h = harness(); + const started = deferred(); + const gate = deferred(); + h.publish.mockImplementationOnce(async () => { + started.resolve(); + await gate.promise; + }); + const run = h.owner.capability.run("delete", id); + await started.promise; + h.owner.dispose(); + gate.resolve(); + await expect(run).rejects.toBeInstanceOf(ChannelLifecycleUnconfirmed); + expect(h.removed).not.toHaveBeenCalled(); + expect(h.acceptDiscovery).not.toHaveBeenCalled(); +}); +it("reads exact relay-owned coordinates fresh both before sign and before publish", async () => { + const h = harness(); + await h.owner.capability.run("delete", id); + const expected = [39000, 39001, 39002].map((kind) => ({ + kinds: [kind], + authors: [relayAuthor], + "#d": [id], + limit: 1, + })); + expect(h.read).toHaveBeenNthCalledWith( + 1, + expected, + expect.objectContaining({ fresh: true, priority: "foreground" }), + ); + expect(h.read).toHaveBeenNthCalledWith( + 2, + expected, + expect.objectContaining({ fresh: true, priority: "foreground" }), + ); + h.owner.dispose(); +}); +it.each([ + [ + ["d", other], + ["p", viewer], + ], + [ + ["d", viewer], + ["p", other], + ], + [ + ["d", viewer], + ["p", viewer], + ["h", "invalid"], + ], +])("invalid DM visibility never hides a conversation %#", async (...tags) => { + const h = harness("owner", "dm"); + h.setVisible([h.record(30622, tags)]); + await h.owner.capability.refreshVisibility(); + expect(h.owner.capability.snapshot()).toMatchObject({ + status: "error", + hidden: [], + }); + h.owner.dispose(); +}); diff --git a/src/features/relay/channel-lifecycle.ts b/src/features/relay/channel-lifecycle.ts new file mode 100644 index 000000000..cf96ad6bd --- /dev/null +++ b/src/features/relay/channel-lifecycle.ts @@ -0,0 +1,308 @@ +import { getEventHash } from "nostr-tools"; +import type { RelayReader } from "./reader"; +import type { RelayWriter } from "./transport"; +import { PublishRejected } from "./outbox"; +import { newer, type RelayEvent } from "./events"; +import { + DM_VISIBILITY_KIND, + exactLifecycleTag, + lifecycleChannelId, + lifecycleRecord, + lifecycleSettings, + lifecycleTemplate, + type ChannelLifecycleAction, + type ChannelLifecycleSettings, +} from "./channel-lifecycle-protocol"; + +/** No automatic or same-dialog retry after a request may have reached the relay. */ +export class ChannelLifecycleUnconfirmed extends Error { + constructor(reason: unknown) { + super( + `The request may have taken effect. Close this dialog and refresh channels before trying again. ${reason instanceof Error ? reason.message : String(reason)}`, + ); + this.name = "ChannelLifecycleUnconfirmed"; + } +} + +export type DmVisibility = Readonly<{ + status: "idle" | "loading" | "ready" | "error"; + hidden: readonly string[]; + error?: string; +}>; +export interface ChannelLifecycleCapability { + readonly available: boolean; + load( + channelId: string, + signal?: AbortSignal, + ): Promise; + run( + action: ChannelLifecycleAction, + channelId: string, + signal?: AbortSignal, + ): Promise; + snapshot(): DmVisibility; + subscribe(listener: () => void): () => void; + refreshVisibility(): Promise; +} + +export function createChannelLifecycle({ + reader, + writer, + viewer, + relayAuthor, + canAccess, + acceptDiscovery, + removed, +}: { + reader?: RelayReader | undefined; + writer?: RelayWriter | undefined; + viewer: string; + relayAuthor: string; + canAccess(id: string): boolean; + acceptDiscovery(events: readonly RelayEvent[]): void; + removed(id: string): void; +}) { + let closed = false; + let busy = false; + let epoch = 0; + const controllers = new Set(); + const listeners = new Set<() => void>(); + let snapshot: DmVisibility = Object.freeze({ + status: "idle", + hidden: Object.freeze([]), + }); + let visibility: RelayEvent | undefined; + let refresh: Promise | undefined; + const available = !!reader && !!writer; + const emit = (next: DmVisibility) => { + snapshot = Object.freeze(next); + for (const listener of listeners) listener(); + }; + function assertAccess(id: string) { + if (closed) throw new DOMException("Relay session closed", "AbortError"); + if (!canAccess(id)) + throw new Error("Channel access unavailable; refresh membership"); + } + async function owned( + work: (signal: AbortSignal) => Promise, + caller?: AbortSignal, + ): Promise { + if (closed) throw new DOMException("Relay session closed", "AbortError"); + const controller = new AbortController(); + controllers.add(controller); + const signal = AbortSignal.any([ + controller.signal, + AbortSignal.timeout(20_000), + ...(caller ? [caller] : []), + ]); + try { + signal.throwIfAborted(); + return await work(signal); + } finally { + controllers.delete(controller); + } + } + async function read( + kinds: readonly number[], + id: string, + signal: AbortSignal, + member = false, + ) { + if (!reader) + throw new Error("Channel actions are unavailable on this connection"); + const events = await reader.read( + kinds.map((kind) => ({ + kinds: [kind], + authors: [relayAuthor], + "#d": [id], + limit: 1, + ...(member ? { "#p": [viewer] } : {}), + })), + { signal, fresh: true, priority: "foreground" }, + ); + signal.throwIfAborted(); + if (events.some((event) => !kinds.includes(event.kind))) + throw new Error("Unexpected channel state response"); + return events; + } + async function load(id: string, signal: AbortSignal) { + assertAccess(id); + const events = await read([39000, 39001, 39002], id, signal); + assertAccess(id); + return lifecycleSettings(events, id, viewer, relayAuthor); + } + async function readVisibility(signal: AbortSignal) { + const events = await read([DM_VISIBILITY_KIND], viewer, signal, true); + const record = lifecycleRecord( + events, + DM_VISIBILITY_KIND, + viewer, + relayAuthor, + ); + if (record) { + if (exactLifecycleTag(record, "p") !== viewer || record.content !== "") + throw new Error("Invalid DM visibility snapshot"); + const hidden = record.tags + .filter(([key]) => key === "h") + .map((entry) => { + if (entry.length !== 2) throw new Error("Invalid hidden DM"); + return lifecycleChannelId(entry[1] ?? ""); + }); + if (newer(visibility, record) === record) { + visibility = record; + emit({ status: "ready", hidden: Object.freeze([...new Set(hidden)]) }); + } + } else if (!visibility) + emit({ status: "ready", hidden: Object.freeze([]) }); + if (snapshot.status !== "ready") + emit({ status: "ready", hidden: snapshot.hidden }); + return snapshot.hidden; + } + const capability: ChannelLifecycleCapability = Object.freeze({ + available, + snapshot: () => snapshot, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + refreshVisibility() { + if (refresh) return refresh; + if (!reader || closed) return Promise.resolve(); + const started = epoch; + emit({ status: "loading", hidden: snapshot.hidden }); + const operation = owned(async (signal) => { + await readVisibility(signal); + }) + .catch((error: unknown) => { + if (started === epoch && !closed) + emit({ + status: "error", + hidden: snapshot.hidden, + error: error instanceof Error ? error.message : String(error), + }); + }) + .finally(() => { + if (refresh === operation) refresh = undefined; + }); + refresh = operation; + return operation; + }, + load(value: string, signal?: AbortSignal) { + return owned((signal) => load(lifecycleChannelId(value), signal), signal); + }, + async run( + action: ChannelLifecycleAction, + value: string, + caller?: AbortSignal, + ) { + if (!available || !writer) + throw new Error("Channel actions are unavailable on this connection"); + if (busy) throw new Error("Another channel action is still in progress"); + const id = lifecycleChannelId(value); + busy = true; + let publicationStarted = false; + try { + await owned(async (signal) => { + const authorize = async () => { + const settings = await load(id, signal); + const permitted = { + archive: settings.canArchive, + delete: settings.canDelete, + leave: settings.canLeave, + hide: settings.canHide, + }[action]; + if (!permitted) + throw new Error( + action === "leave" && settings.leaveReason + ? settings.leaveReason + : "This action is no longer permitted. Refresh channel permissions.", + ); + }; + await authorize(); + const template = lifecycleTemplate(action, id); + const signed = await writer.sign(structuredClone(template), signal); + signal.throwIfAborted(); + if ( + signed.pubkey !== viewer || + signed.kind !== template.kind || + signed.created_at !== template.created_at || + signed.content !== template.content || + JSON.stringify(signed.tags) !== JSON.stringify(template.tags) || + getEventHash(signed) !== signed.id + ) + throw new Error("Signer changed the channel lifecycle command"); + await authorize(); + signal.throwIfAborted(); + publicationStarted = true; + await writer.publish(signed, signal); + // An accepted write may precede its relay-owned side effects. Do not + // optimistically revoke membership or confuse a failed read with absence. + for (const delay of [0, 250, 750, 1500]) { + signal.throwIfAborted(); + if (delay) + await new Promise((resolve, reject) => { + const abort = () => { + clearTimeout(timer); + reject(signal.reason); + }; + const timer = setTimeout(() => { + signal.removeEventListener("abort", abort); + resolve(); + }, delay); + signal.addEventListener("abort", abort, { once: true }); + }); + if (action === "hide") { + if ((await readVisibility(signal)).includes(id)) return; + } else { + const kind = action === "leave" ? 39002 : 39000; + const events = await read([kind], id, signal, action === "leave"); + const record = lifecycleRecord(events, kind, id, relayAuthor); + if (action === "archive") { + if ( + record && + exactLifecycleTag(record, "archived") === "true" + ) { + acceptDiscovery([record]); + return; + } + } else if (!record) { + removed(id); + return; + } + } + } + throw new Error( + "The relay accepted the request, but the change is not confirmed. Refresh channels before trying again.", + ); + }, caller); + } catch (error) { + if (publicationStarted && !(error instanceof PublishRejected)) + throw new ChannelLifecycleUnconfirmed(error); + throw error; + } finally { + busy = false; + } + }, + }); + return { + capability, + cancel() { + for (const controller of controllers) controller.abort(); + }, + clear() { + epoch++; + for (const controller of controllers) controller.abort(); + refresh = undefined; + visibility = undefined; + emit({ status: "idle", hidden: Object.freeze([]) }); + }, + dispose() { + closed = true; + epoch++; + for (const controller of controllers) controller.abort(); + listeners.clear(); + }, + }; +} diff --git a/src/features/relay/session.ts b/src/features/relay/session.ts index b49a91773..d36ebb420 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -17,6 +17,7 @@ import { createAgentMemories } from "../agents/memory"; import type { PresenceActivity } from "../presence/activity"; import { bindNames, type IdentityNames } from "../identity-names/service"; import { sessionMetadata } from "../sessions/metadata"; +import { createChannelLifecycle } from "./channel-lifecycle"; import { createWorkflows } from "../workflows/capability"; import { isWorkflowOperation } from "../workflows/protocol"; import { @@ -316,6 +317,7 @@ export function createRelaySession( try { accessEpoch++; cancelUploads(); + lifecycle.cancel(); typing.clear(); // Filters cannot tell us ownership of broad/ID/reference reads. Infrequent // authoritative access loss cancels them all, not merely explicit #h reads. @@ -571,6 +573,16 @@ export function createRelaySession( // NIP-34/NIP-MP metadata is global; channel tags are associations, not ACLs. return events; }); + const lifecycle = createChannelLifecycle({ + reader: transport ? requests.reader : undefined, + writer: transport?.channelLifecycle, + viewer: transport?.viewer ?? "", + relayAuthor: transport?.relayAuthor ?? "", + canAccess: (id) => !closed && canAccess(id), + acceptDiscovery: (events) => channels.acceptDiscovery(events), + removed: (id) => + channels.denyChannel(id, new Error("Channel is no longer available")), + }); const workflows = createWorkflows({ reader: transport ? verified : undefined, viewer: transport?.viewer ?? "", @@ -1296,6 +1308,7 @@ export function createRelaySession( }, } : undefined, + channelLifecycle: lifecycle.capability, agentActivity: activity.queries, agentMemories: memories.capability, archives: archives.queries, @@ -1795,6 +1808,7 @@ export function createRelaySession( typing.clear(); sidebarPreferences.clear(); channelKit.clear(); + lifecycle.clear(); // New windows must not yield to or receive errors from retired owners. catchups.clear(); catchupQueue.clear(); @@ -1821,6 +1835,7 @@ export function createRelaySession( memories.dispose(); presence.dispose(); sidebarPreferences.dispose(); + lifecycle.dispose(); stopInterests(); stopWarmPreferences(); traffic?.dispose(); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index e7e8c6209..96cc56060 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -71,6 +71,8 @@ export interface ReadTransport { signal: AbortSignal, ) => Promise; readonly workflows?: WorkflowHost; + /** Narrow lifecycle signer/publisher; never supplied to the message outbox. */ + readonly channelLifecycle?: RelayWriter; /** Purpose-bound observer decoding on the shared host live stream. */ readonly agentActivity?: boolean; /** Explicit relay-advertised session command support. */ @@ -249,6 +251,7 @@ export async function connectBrokerTransport( projectGit?: boolean; attachmentUploads?: boolean; directMessages?: boolean; + channelLifecycle?: boolean; relayUrl?: string; live?: boolean; presence?: boolean; @@ -590,6 +593,40 @@ export async function connectBrokerTransport( }, } : {}), + ...(session.channelLifecycle === true + ? { + channelLifecycle: { + async sign(template: EventTemplate, signal: AbortSignal) { + const response = await fetch( + `${endpoint}/channel-lifecycle-sign`, + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(template), + signal, + }, + ); + if (!response.ok) + throw new Error((await readApiFailure(response)).error); + return eventDto(await response.json()); + }, + async publish(event: RelayEvent, signal: AbortSignal) { + const response = await fetch( + `${endpoint}/channel-lifecycle-publish`, + { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(event), + signal, + }, + ); + return acceptPublish(response, event.id); + }, + }, + } + : {}), ...(session.writeKinds ? { writer: { diff --git a/tests/browser/channel-lifecycle.spec.mjs b/tests/browser/channel-lifecycle.spec.mjs new file mode 100644 index 000000000..233c2fbdb --- /dev/null +++ b/tests/browser/channel-lifecycle.spec.mjs @@ -0,0 +1,200 @@ +import { test, expect } from "./fixture.mjs"; +async function openLifecycle(page, app) { + await page.goto(app.origin); + await page + .getByRole("button", { name: "Messages", exact: true }) + .first() + .click(); + await page + .getByRole("navigation", { name: "Subscribed channels" }) + .getByRole("button", { name: "Alpha", exact: true }) + .click(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); +} + +test.use({ + productionBroker: true, + channelLifecycle: true, + historyCounts: { alpha: 2, beta: 1 }, +}); + +// Native modal focus/escape and real menu -> modal handoff require a browser. +// Role/type/signing/cancellation matrices remain in domain and mounted tests. +test("archive confirmation returns focus on cancel and navigates after confirmed removal", async ({ + page, + app, +}, testInfo) => { + await page.addInitScript(() => + localStorage.setItem("buzz-appearance.v1", "dark"), + ); + await openLifecycle(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const row = sidebar.getByRole("button", { + name: "Lifecycle channel", + exact: true, + }); + await row.click(); + await expect( + page.getByRole("textbox", { + name: "Message #Lifecycle channel", + exact: true, + }), + ).toBeVisible(); + await row.focus(); + await page.keyboard.press("Shift+F10"); + const menu = page.getByRole("menu", { + name: "Actions for Lifecycle channel", + }); + await expect( + menu.getByRole("menuitem", { name: "Archive channel…" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Leave channel…" }), + ).toHaveAttribute("aria-disabled", "true"); + await menu.screenshot({ path: testInfo.outputPath("lifecycle-menu.png") }); + await menu.getByRole("menuitem", { name: "Archive channel…" }).click(); + const dialog = page.getByRole("dialog", { + name: "Archive channel: Lifecycle channel", + }); + await expect(dialog).toBeVisible(); + await expect(dialog.getByRole("button", { name: "Cancel" })).toBeFocused(); + await dialog.screenshot({ + path: testInfo.outputPath("lifecycle-confirmation.png"), + }); + await page.keyboard.press("Escape"); + await expect(dialog).toHaveCount(0); + await expect(row).toBeFocused(); + expect(app.report.lifecyclePublications ?? []).toHaveLength(0); + await row.click({ button: "right" }); + await menu.getByRole("menuitem", { name: "Archive channel…" }).click(); + await dialog + .getByRole("button", { name: "Archive channel", exact: true }) + .click(); + await expect(dialog).toHaveCount(0); + await expect(row).toHaveCount(0); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + expect(app.report.lifecyclePublications).toHaveLength(1); + expect(app.report.lifecyclePublications[0].kind).toBe(9002); + expect(app.report.unexpected).toEqual([]); +}); + +// The production page must project the separate per-viewer visibility snapshot +// after remount/reload, while membership/authorized channel reads remain intact. +test("DM hide is per-viewer visibility, survives reload and never sends Leave or Delete", async ({ + page, + app, +}) => { + await openLifecycle(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const row = sidebar.locator( + '[data-channel-id="22222222-2222-4222-8222-222222222222"]', + ); + await expect(row).toBeVisible(); + await row.click(); + const composer = page.getByRole("textbox", { name: /^Message #/ }); + await expect(composer).toBeVisible(); + const composerName = await composer.getAttribute("aria-label"); + const conversationUrl = page.url(); + await row.click({ button: "right" }); + const menu = page.getByRole("menu"); + await expect( + menu.getByRole("menuitem", { name: "Hide conversation…" }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Delete channel…" }), + ).toHaveCount(0); + await expect( + menu.getByRole("menuitem", { name: "Leave channel…" }), + ).toHaveCount(0); + await menu.getByRole("menuitem", { name: "Hide conversation…" }).click(); + await page + .getByRole("dialog") + .getByRole("button", { name: "Hide conversation", exact: true }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(row).toHaveCount(0); + await page.reload(); + await expect( + sidebar.getByRole("button", { name: "Alpha", exact: true }), + ).toBeVisible(); + // Wait for the actual visibility read to complete before asserting absence. + await expect + .poll( + () => + app.report.queries.filter((query) => + JSON.stringify(query).includes("30622"), + ).length, + ) + .toBeGreaterThan(1); + await expect(row).toHaveCount(0); + // A hidden row is not an access denial. Exact navigation still opens its messages. + await page.goto(conversationUrl); + await expect( + page.getByRole("textbox", { name: composerName, exact: true }), + ).toBeVisible(); + await expect(row).toHaveCount(0); + expect(app.report.lifecyclePublications.map((event) => event.kind)).toEqual([ + 41012, + ]); + expect(app.report.unexpected).toEqual([]); +}); + +// Delete takes the access-purge route (archive does not), across the real broker, +// session, native dialog and navigation. Role permutations remain below the browser. +test("typed delete confirmation purges the selected channel and survives reload", async ({ + page, + app, +}) => { + await openLifecycle(page, app); + const sidebar = page.getByRole("navigation", { name: "Subscribed channels" }); + const row = sidebar.getByRole("button", { + name: "Lifecycle channel", + exact: true, + }); + await row.click(); + await expect( + page.getByRole("textbox", { + name: "Message #Lifecycle channel", + exact: true, + }), + ).toBeVisible(); + await row.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Delete channel…" }).click(); + const dialog = page.getByRole("dialog", { + name: "Delete channel: Lifecycle channel", + }); + const confirm = dialog.getByRole("button", { + name: "Delete channel", + exact: true, + }); + await expect(confirm).toBeDisabled(); + await dialog + .getByRole("textbox", { name: "Channel name confirmation" }) + .fill("wrong name"); + await expect(confirm).toBeDisabled(); + await dialog + .getByRole("textbox", { name: "Channel name confirmation" }) + .fill("Lifecycle channel"); + await confirm.click(); + await expect(dialog).toHaveCount(0); + await expect(row).toHaveCount(0); + await expect( + sidebar.getByRole("button", { name: "Alpha", exact: true }), + ).toBeFocused(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await page.reload(); + await expect( + page.getByRole("textbox", { name: "Message #Alpha", exact: true }), + ).toBeVisible(); + await expect(row).toHaveCount(0); + expect(app.report.lifecyclePublications.map((event) => event.kind)).toEqual([ + 9008, + ]); + expect(app.report.unexpected).toEqual([]); +}); diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 761042ca3..8f9c0d2be 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -38,6 +38,7 @@ export const test = base.extend({ sessionParents: [{}, { option: true }], sidebarUnread: [false, { option: true }], savedSidebar: [false, { option: true }], + channelLifecycle: [false, { option: true }], expectedPageFailure: [false, { option: true }], largeSidebar: [false, { option: true }], iconCongestion: [false, { option: true }], @@ -67,6 +68,7 @@ export const test = base.extend({ sessionParents, sidebarUnread, savedSidebar, + channelLifecycle, expectedPageFailure, largeSidebar, iconCongestion, @@ -145,8 +147,30 @@ export const test = base.extend({ : dmLabels ? ["dm-peer"] : []; + const lifecycleRows = channelLifecycle + ? [ + { + id: "11111111-1111-4111-8111-111111111111", + name: "Lifecycle channel", + type: "stream", + }, + { + id: "22222222-2222-4222-8222-222222222222", + name: "Lifecycle DM", + type: "dm", + }, + ] + : []; + const archivedIds = new Set(); + const hiddenDmIds = new Set(); + let lifecycleTime = 1700000001; const rosterIds = [ - ...new Set([...channels, ...dmIds, ...Object.values(sessionParents)]), + ...new Set([ + ...channels, + ...dmIds, + ...lifecycleRows.map((row) => row.id), + ...Object.values(sessionParents), + ]), ]; if (savedSidebar) { const key = nip44.v2.utils.getConversationKey(userKey, viewer); @@ -207,7 +231,8 @@ export const test = base.extend({ ); const historyDurationMs = performance.now() - historyStarted; for (const community of ["primary", "secondary"]) - for (const id of dmIds) histories.set(`${community}/${id}`, []); + for (const id of [...dmIds, ...lifecycleRows.map((row) => row.id)]) + histories.set(`${community}/${id}`, []); const targetEvents = []; let searchTarget; if (openSearch) { @@ -492,6 +517,37 @@ export const test = base.extend({ return filter.authors.map((author) => sign(20001, [["p", author]], "online"), ); + if (filter.kinds?.includes(30622)) + return channelLifecycle + ? [ + sign( + 30622, + [ + ["d", viewer], + ["p", viewer], + ...[...hiddenDmIds].map((id) => ["h", id]), + ], + "", + relayKey, + lifecycleTime, + ), + ] + : []; + if (filter.kinds?.includes(39001)) + return lifecycleRows + .filter((row) => filter["#d"]?.includes(row.id)) + .map((row) => + sign( + 39001, + [ + ["d", row.id], + ["p", viewer, "owner"], + ], + "", + relayKey, + lifecycleTime, + ), + ); if (filter.kinds?.includes(39002)) return rosterIds .filter((id) => !filter["#d"] || filter["#d"].includes(id)) @@ -508,23 +564,37 @@ export const test = base.extend({ return [...rosterIds, ...(openSearch ? ["open"] : [])] .filter((id) => !filter["#d"] || filter["#d"].includes(id)) .map((id) => - sign(39000, [ - ["d", id], - ["name", id === "alpha" ? "Alpha" : id === "beta" ? "Beta" : id], - ...(id === "open" ? [["public"], ["t", "stream"]] : []), - ...(dmIds.includes(id) ? [["t", "dm"], ["hidden"]] : []), - ...(sessionChannels.includes(id) - ? [ - ["t", "stream"], - ["private"], - [ - "about", - `Buzz session (buzz.sessions/v1)${sessionParents[id] ? `\nparent:${sessionParents[id]}` : ""}`, - ], - ] - : []), - ...(hiddenChannels.has(id) ? [["hidden"]] : []), - ]), + sign( + 39000, + [ + ["d", id], + [ + "name", + lifecycleRows.find((row) => row.id === id)?.name ?? + (id === "alpha" ? "Alpha" : id === "beta" ? "Beta" : id), + ], + ...lifecycleRows + .filter((row) => row.id === id) + .map((row) => ["t", row.type]), + ...(archivedIds.has(id) ? [["archived", "true"]] : []), + ...(id === "open" ? [["public"], ["t", "stream"]] : []), + ...(dmIds.includes(id) ? [["t", "dm"], ["hidden"]] : []), + ...(sessionChannels.includes(id) + ? [ + ["t", "stream"], + ["private"], + [ + "about", + `Buzz session (buzz.sessions/v1)${sessionParents[id] ? `\nparent:${sessionParents[id]}` : ""}`, + ], + ] + : []), + ...(hiddenChannels.has(id) ? [["hidden"]] : []), + ], + "", + relayKey, + lifecycleTime, + ), ); if (filter.kinds?.includes(30078)) { const events = [...readEvents.get(community).values()]; @@ -849,13 +919,30 @@ export const test = base.extend({ // The production broker advertises read-state writes for every session, // not only tests opting into complete snapshot reads. acceptPublication: acceptReadPublication, - ...(actionProfile + ...(actionProfile || channelLifecycle ? { latencyMs: 40, holdOlder: false, acceptPublication: (community, event) => { expect(verifyEvent(event)).toBe(true); expect(event.pubkey).toBe(viewer); + if ( + channelLifecycle && + [9002, 9008, 9022, 41012].includes(event.kind) + ) { + const id = event.tags.find(([key]) => key === "h")?.[1]; + expect(lifecycleRows.some((row) => row.id === id)).toBe( + true, + ); + if (event.kind === 9002) archivedIds.add(id); + if (event.kind === 41012) hiddenDmIds.add(id); + if (event.kind === 9008 || event.kind === 9022) + rosterIds.splice(rosterIds.indexOf(id), 1); + lifecycleTime++; + report.lifecyclePublications ??= []; + report.lifecyclePublications.push(event); + return; + } if (event.kind === 30078) return acceptReadPublication(community, event); expect([9, 7]).toContain(event.kind); diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index d56b8ea52..8c523ff94 100644 --- a/tests/browser/policy-relay.mjs +++ b/tests/browser/policy-relay.mjs @@ -194,6 +194,32 @@ export function policyRelay({ ), ); } + if ( + filters.length === 3 && + filters.every( + (filter) => + filter.kinds?.length === 1 && + [39000, 39001, 39002].includes(filter.kinds[0]), + ) + ) { + expect(filters.map((filter) => filter.kinds[0])).toEqual([ + 39000, 39001, 39002, + ]); + expect(new Set(filters.map((filter) => filter["#d"]?.[0])).size).toBe( + 1, + ); + for (const filter of filters) { + expect(filter.limit).toBe(1); + report.queries.push({ + community: communityOf(url), + filter, + at: performance.now(), + }); + } + return Response.json( + filters.flatMap((filter) => answer(communityOf(url), filter)), + ); + } if (filters.length !== 1) { // Sidebar preferences read only these three exact own-author coordinates. expect(filters).toHaveLength(3); From 487c0c5b5482bb897fad4ef7eee7b3efe3560abe Mon Sep 17 00:00:00 2001 From: Carl Date: Tue, 22 Sep 2026 10:48:43 -0700 Subject: [PATCH 02/11] fix(channels): accept relay membership hints for lifecycle actions Accept the relay's four-field membership roster without using hints as administrator authority. Exercise valid and malformed roster shapes, correct browser fixtures, and offer safe retry copy instead of raw protocol errors. Signed-off-by: Carl --- docs/channels.md | 5 ++ .../channels/ChannelLifecycleMenu.test.tsx | 6 ++- src/bundled/channels/ChannelLifecycleMenu.tsx | 13 +++-- .../relay/channel-lifecycle-protocol.ts | 5 +- src/features/relay/channel-lifecycle.test.ts | 51 ++++++++++++++++++- tests/browser/fixture.mjs | 9 +++- 6 files changed, 75 insertions(+), 14 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index ab01e5984..63c85fa93 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -233,6 +233,11 @@ 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. +Membership accepts NIP-29 `p` tags with optional relay and role fields +(`["p", pubkey, relay_hint?, role?]`), including the relay's four-field roster. +These fields never substitute for the separate administrator record. Invalid +member keys and duplicate entries still fail closed. Failed menu permission reads +show "Channel actions unavailable" with retry, not raw protocol errors. Each command has explicit confirmation; Delete additionally requires the channel name. The lifecycle owner rechecks authority before signing and again before diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index 87cd4a2e2..81f492914 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -73,7 +73,9 @@ it("shows fresh loading and the last-owner boundary", async () => { 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")); + lifecycle.load.mockRejectedValueOnce( + new Error("Malformed channel membership state"), + ); render( @@ -87,7 +89,7 @@ it("failed permission reads offer retry rather than stale destructive actions", , ); expect((await screen.findByRole("alert")).textContent).toBe( - "permissions offline", + "Channel actions unavailable", ); expect( screen.queryByRole("menuitem", { name: "Archive channel…" }), diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx index 0def0bb4f..364b152bd 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -19,21 +19,20 @@ export function ChannelLifecycleMenu({ disabled: boolean; }) { const [state, setState] = useState(); - const [error, setError] = useState(""); + const [failed, setFailed] = useState(false); 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(""); + setFailed(false); 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)); + () => { + if (!controller.signal.aborted) setFailed(true); }, ); return () => controller.abort(); @@ -44,10 +43,10 @@ export function ChannelLifecycleMenu({ Channel actions unavailable on this connection ); - if (error) + if (failed) return ( <> -

{error}

+

Channel actions unavailable

key === "p")) { const member = entry[1]; if ( - entry.length !== 2 || + // NIP-29 membership permits ["p", key, relay_hint?, role?]. + // These hints do not grant authority; roles come from the 39001 record. + entry.length < 2 || + entry.length > 4 || !member || !PUBKEY.test(member) || members.has(member) diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 3892cc8d0..3804fcd35 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -47,8 +47,8 @@ function harness(role = "owner", type = "stream", owners = 1) { roles(role), record(39002, [ ["d", id], - ["p", viewer], - ["p", other], + ["p", viewer, "", role], + ["p", other, "", owners > 1 ? "owner" : "member"], ]), ]; let visible: RelayEvent[] = []; @@ -135,6 +135,53 @@ describe("type and role boundaries", () => { h.owner.dispose(); }, ); + it.each( + [[], ["wss://relay.example"], ["", "owner"]].map((hints) => ({ hints })), + )( + "accepts membership hints %j without granting administrator authority", + async ({ hints }) => { + const h = harness("member"); + h.setEvents([ + ...h.getEvents().filter((event) => event.kind !== 39002), + h.record(39002, [ + ["d", id], + ["p", viewer, ...hints], + ]), + ]); + expect(await h.owner.capability.load(id)).toMatchObject({ + canArchive: false, + canDelete: false, + canLeave: true, + }); + h.owner.dispose(); + }, + ); + it.each( + [ + [["p"]], + [["p", "not-a-key"]], + [["p", viewer, "", "owner", "unexpected"]], + [ + ["p", viewer], + ["p", viewer, "", "owner"], + ], + ].map((entries) => ({ entries })), + )( + "rejects malformed or duplicate member entries %j before signing", + async ({ entries }) => { + const h = harness(); + h.setEvents([ + ...h.getEvents().filter((event) => event.kind !== 39002), + h.record(39002, [["d", id], ...entries]), + ]); + await expect(h.owner.capability.run("delete", id)).rejects.toThrow( + "Malformed", + ); + expect(h.sign).not.toHaveBeenCalled(); + expect(h.publish).not.toHaveBeenCalled(); + h.owner.dispose(); + }, + ); it("validates the complete administrator record, not just the viewer's first match", () => { const h = harness(); h.setEvents([ diff --git a/tests/browser/fixture.mjs b/tests/browser/fixture.mjs index 8f9c0d2be..7fdf5c529 100644 --- a/tests/browser/fixture.mjs +++ b/tests/browser/fixture.mjs @@ -554,10 +554,15 @@ export const test = base.extend({ .map((id) => sign(39002, [ ["d", id], - ["p", viewer], + [ + "p", + viewer, + "", + lifecycleRows.some((row) => row.id === id) ? "owner" : "member", + ], ...participants .slice(dmIds.indexOf(id) * 8, (dmIds.indexOf(id) + 1) * 8) - .map((pubkey) => ["p", pubkey]), + .map((pubkey) => ["p", pubkey, "", "member"]), ]), ); if (filter.kinds?.includes(39000)) From 7a28dd70fb6a543eab54a923be611c1610dfed61 Mon Sep 17 00:00:00 2001 From: Carl Date: Tue, 22 Sep 2026 17:02:14 -0700 Subject: [PATCH 03/11] fix(channels): simplify lifecycle action menus Remove action ellipses and omit forbidden Leave actions instead of displaying a disabled item and ownership guidance. Preserve confirmation and domain authorization, with mounted and browser regressions. Signed-off-by: Carl --- docs/channels.md | 4 +- .../channels/ChannelLifecycleMenu.test.tsx | 59 +++++++++++++++++-- src/bundled/channels/ChannelLifecycleMenu.tsx | 18 +++--- tests/browser/channel-lifecycle.spec.mjs | 31 ++++++---- 4 files changed, 85 insertions(+), 27 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index 63c85fa93..de4a18a9e 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -230,7 +230,9 @@ and modeled upstream I/O; it does not send messages to a live community. 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 +Delete requires a direct owner role; the last owner cannot Leave. The menu omits +Leave when it is forbidden, without an ownership-transfer explanation. Action +labels have no trailing ellipsis; confirmation dialogs are unchanged. 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. Membership accepts NIP-29 `p` tags with optional relay and role fields diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index 81f492914..feb5a335a 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -63,13 +63,58 @@ it("shows fresh loading and the last-owner boundary", async () => { expect( await screen.findByText("Checking channel permissions…"), ).toBeDefined(); - expect(screen.queryByText("Delete channel…")).toBeNull(); + 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…" })); + const remove = await screen.findByRole("menuitem", { + name: "Delete channel", + }); + expect(screen.queryByRole("menuitem", { name: /^Leave channel/ })).toBeNull(); + expect( + screen.queryByText("Transfer ownership before leaving the channel."), + ).toBeNull(); + expect( + screen.getAllByRole("menuitem").map((item) => item.textContent), + ).toEqual(["Archive channel", "Delete channel"]); + await user.click(remove); expect(choose).toHaveBeenCalledWith("delete"); }); +it.each([ + { action: "leave", label: "Leave channel", channelType: "stream" }, + { action: "hide", label: "Hide conversation", channelType: "dm" }, +] as const)( + "offers $label without an ellipsis when permitted", + async ({ action, label, channelType }) => { + const user = userEvent.setup(); + const lifecycle = capability(); + lifecycle.load.mockResolvedValue({ + channelId: "id", + channelType, + canArchive: false, + canDelete: false, + canLeave: action === "leave", + canHide: action === "hide", + }); + const choose = vi.fn(); + render( + + + + + , + ); + const item = await screen.findByRole("menuitem", { + name: label, + }); + expect(screen.getAllByRole("menuitem")).toHaveLength(1); + await user.click(item); + expect(choose).toHaveBeenCalledWith(action); + }, +); it("failed permission reads offer retry rather than stale destructive actions", async () => { const user = userEvent.setup(); const lifecycle = capability(); @@ -92,13 +137,15 @@ it("failed permission reads offer retry rather than stale destructive actions", "Channel actions unavailable", ); expect( - screen.queryByRole("menuitem", { name: "Archive channel…" }), + 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…" }), + await screen.findByRole("menuitem", { + name: "Archive channel", + }), ).toBeDefined(); }); it("confirmation, pending lockout and failed-write recovery stay in the actual dialog", async () => { diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx index 364b152bd..508feee51 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -62,27 +62,25 @@ export function ChannelLifecycleMenu({ <> {state.canHide ? ( choose("hide")}> - Hide conversation… + Hide conversation ) : ( <> {state.canArchive && ( choose("archive")}> - Archive channel… + Archive channel )} {state.canDelete && ( choose("delete")}> - Delete channel… + Delete channel + + )} + {state.canLeave && ( + choose("leave")}> + Leave channel )} - choose("leave")} - > - Leave channel… - - {state.leaveReason &&

{state.leaveReason}

} )} diff --git a/tests/browser/channel-lifecycle.spec.mjs b/tests/browser/channel-lifecycle.spec.mjs index 233c2fbdb..fbb818ad0 100644 --- a/tests/browser/channel-lifecycle.spec.mjs +++ b/tests/browser/channel-lifecycle.spec.mjs @@ -48,13 +48,18 @@ test("archive confirmation returns focus on cancel and navigates after confirmed name: "Actions for Lifecycle channel", }); await expect( - menu.getByRole("menuitem", { name: "Archive channel…" }), + menu.getByRole("menuitem", { name: "Archive channel", exact: true }), ).toBeVisible(); await expect( - menu.getByRole("menuitem", { name: "Leave channel…" }), - ).toHaveAttribute("aria-disabled", "true"); + menu.getByRole("menuitem", { name: /^Leave channel/ }), + ).toHaveCount(0); + await expect( + menu.getByText("Transfer ownership before leaving the channel."), + ).toHaveCount(0); await menu.screenshot({ path: testInfo.outputPath("lifecycle-menu.png") }); - await menu.getByRole("menuitem", { name: "Archive channel…" }).click(); + await menu + .getByRole("menuitem", { name: "Archive channel", exact: true }) + .click(); const dialog = page.getByRole("dialog", { name: "Archive channel: Lifecycle channel", }); @@ -68,7 +73,9 @@ test("archive confirmation returns focus on cancel and navigates after confirmed await expect(row).toBeFocused(); expect(app.report.lifecyclePublications ?? []).toHaveLength(0); await row.click({ button: "right" }); - await menu.getByRole("menuitem", { name: "Archive channel…" }).click(); + await menu + .getByRole("menuitem", { name: "Archive channel", exact: true }) + .click(); await dialog .getByRole("button", { name: "Archive channel", exact: true }) .click(); @@ -102,15 +109,17 @@ test("DM hide is per-viewer visibility, survives reload and never sends Leave or await row.click({ button: "right" }); const menu = page.getByRole("menu"); await expect( - menu.getByRole("menuitem", { name: "Hide conversation…" }), + menu.getByRole("menuitem", { name: "Hide conversation", exact: true }), ).toBeVisible(); await expect( - menu.getByRole("menuitem", { name: "Delete channel…" }), + menu.getByRole("menuitem", { name: "Delete channel", exact: true }), ).toHaveCount(0); await expect( - menu.getByRole("menuitem", { name: "Leave channel…" }), + menu.getByRole("menuitem", { name: "Leave channel", exact: true }), ).toHaveCount(0); - await menu.getByRole("menuitem", { name: "Hide conversation…" }).click(); + await menu + .getByRole("menuitem", { name: "Hide conversation", exact: true }) + .click(); await page .getByRole("dialog") .getByRole("button", { name: "Hide conversation", exact: true }) @@ -163,7 +172,9 @@ test("typed delete confirmation purges the selected channel and survives reload" }), ).toBeVisible(); await row.click({ button: "right" }); - await page.getByRole("menuitem", { name: "Delete channel…" }).click(); + await page + .getByRole("menuitem", { name: "Delete channel", exact: true }) + .click(); const dialog = page.getByRole("dialog", { name: "Delete channel: Lifecycle channel", }); From e71b56310d5a8fe3b1e6e5cfea832e0c894ee631 Mon Sep 17 00:00:00 2001 From: Carl Date: Tue, 22 Sep 2026 17:06:29 -0700 Subject: [PATCH 04/11] fix(channels): hide the lifecycle permission loading row Wait silently for fresh menu permissions while retaining failed-read retry and all authorization checks. Cover pending initial and retry lookups without exposing unchecked actions. Signed-off-by: Carl --- docs/channels.md | 3 ++- .../channels/ChannelLifecycleMenu.test.tsx | 16 +++++++++++----- src/bundled/channels/ChannelLifecycleMenu.tsx | 3 +-- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/docs/channels.md b/docs/channels.md index de4a18a9e..9b4d1be8c 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -239,7 +239,8 @@ Membership accepts NIP-29 `p` tags with optional relay and role fields (`["p", pubkey, relay_hint?, role?]`), including the relay's four-field roster. These fields never substitute for the separate administrator record. Invalid member keys and duplicate entries still fail closed. Failed menu permission reads -show "Channel actions unavailable" with retry, not raw protocol errors. +show "Channel actions unavailable" with retry, not raw protocol errors. Pending +permission reads show no loading row; actions appear only after verification. Each command has explicit confirmation; Delete additionally requires the channel name. The lifecycle owner rechecks authority before signing and again before diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index feb5a335a..a77629d70 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -42,7 +42,7 @@ function capability() { refreshVisibility: async () => {}, } satisfies ChannelLifecycleCapability; } -it("shows fresh loading and the last-owner boundary", async () => { +it("waits silently for fresh permissions and preserves the last-owner boundary", async () => { const user = userEvent.setup(); const lifecycle = capability(); const choose = vi.fn(); @@ -60,10 +60,10 @@ it("shows fresh loading and the last-owner boundary", async () => { , ); - expect( - await screen.findByText("Checking channel permissions…"), - ).toBeDefined(); - expect(screen.queryByText("Delete channel")).toBeNull(); + await waitFor(() => expect(lifecycle.load).toHaveBeenCalledOnce()); + expect(screen.queryByText("Checking channel permissions…")).toBeNull(); + expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(choose).not.toHaveBeenCalled(); gate.resolve(settings); const remove = await screen.findByRole("menuitem", { name: "Delete channel", @@ -139,9 +139,15 @@ it("failed permission reads offer retry rather than stale destructive actions", expect( screen.queryByRole("menuitem", { name: "Archive channel" }), ).toBeNull(); + const retry = deferred(); + lifecycle.load.mockReturnValueOnce(retry.promise); await user.click( screen.getByRole("menuitem", { name: "Retry channel permissions" }), ); + await waitFor(() => expect(lifecycle.load).toHaveBeenCalledTimes(2)); + expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(screen.queryByRole("alert")).toBeNull(); + retry.resolve(settings); expect( await screen.findByRole("menuitem", { name: "Archive channel", diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx index 508feee51..aa030a2ec 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -56,8 +56,7 @@ export function ChannelLifecycleMenu({
); - if (!state) - return Checking channel permissions…; + if (!state) return null; return ( <> {state.canHide ? ( From 0e9f63f79d79c7421264e9d90349b4d443cd3b56 Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 23 Sep 2026 23:43:36 -0700 Subject: [PATCH 05/11] fix(channels): preserve mainline behavior in lifecycle menus Keep session creation and local DM removal in their existing owners, project only lifecycle actions, and preserve the silent pending-permission menu. Publish lifecycle commands through the current broker socket identity. Retain other members in the Leave fixture and verify viewer-scoped confirmation. Signed-off-by: Carl --- dev/relay-broker-api.test.mjs | 15 +++-- docs/channels.md | 14 ++++- .../channels/ChannelLifecycleMenu.test.tsx | 61 ++++++++++++++++++- src/bundled/channels/ChannelLifecycleMenu.tsx | 21 +++++-- src/features/relay/channel-lifecycle.test.ts | 38 +++++++++++- src/features/relay/transport.ts | 2 +- tests/browser/channel-lifecycle.spec.mjs | 40 +++++++++++- 7 files changed, 171 insertions(+), 20 deletions(-) diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 53f4c7ce8..be3d3a713 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1839,6 +1839,7 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ : [], ), ); + let live; try { const transport = await connectBrokerTransport(h.base); expect(transport.writer.kinds).not.toContain(9008); @@ -1887,10 +1888,13 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ expect(verifyEvent(signed)).toBe(true); expect(signed).toMatchObject(template); expect((await h.post("publish", signed)).status).toBe(400); + await expect( + transport.channelLifecycle.publish(signed, signal), + ).rejects.toBeInstanceOf(PublishRejected); + expect(h.publications).toHaveLength(0); + live = await openBrokerSocket(transport); await transport.channelLifecycle.publish(signed, signal); - expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( - 1, - ); + expect(h.publications).toHaveLength(1); const foreignKey = new Uint8Array(32).fill(5); const foreign = finalizeEvent( { ...template, tags: template.tags.map((tag) => [...tag]) }, @@ -1899,10 +1903,9 @@ test("lifecycle uses dedicated shape-limited host routes, never the message writ expect((await h.post("channel-lifecycle-publish", foreign)).status).toBe( 400, ); - expect(h.calls.filter((call) => call.url.endsWith("/events"))).toHaveLength( - 1, - ); + expect(h.publications).toHaveLength(1); } finally { + live?.dispose(); await h.close(); } }); diff --git a/docs/channels.md b/docs/channels.md index 9b4d1be8c..f103999f0 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -227,6 +227,11 @@ and modeled upstream I/O; it does not send messages to a live community. ## Channel lifecycle +Lifecycle actions extend the existing row’s ⋮ menu and a context menu on the +conversation button. Session creation and child-session navigation keep their +existing owners; sessions do not receive lifecycle actions. This slice adds no +Move/Star/grouping, mute, read controls, or shared-menu restyling. + 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; @@ -240,7 +245,9 @@ Membership accepts NIP-29 `p` tags with optional relay and role fields These fields never substitute for the separate administrator record. Invalid member keys and duplicate entries still fail closed. Failed menu permission reads show "Channel actions unavailable" with retry, not raw protocol errors. Pending -permission reads show no loading row; actions appear only after verification. +permission reads show neither a loading row nor a lifecycle separator; the +separator appears with the resolved actions or unavailable/retry section, and is +omitted when there are no lifecycle items. Actions appear only after verification. Each command has explicit confirmation; Delete additionally requires the channel name. The lifecycle owner rechecks authority before signing and again before @@ -250,7 +257,8 @@ access-loss purge. Commands use narrow development-broker routes, never the mess 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` +Main’s DM × remains local removal, including restoration on new message evidence. +The separate, confirmed Hide conversation action 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 @@ -264,7 +272,7 @@ 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. +(or the neutral Messages page) with a visible sidebar-row focus fallback. ## Performance and correctness carried from Astra diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx index a77629d70..ab1213753 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.test.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, expect, it, vi } from "vitest"; -import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { act, 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 { @@ -56,6 +56,7 @@ it("waits silently for fresh permissions and preserves the last-owner boundary", lifecycle={lifecycle} choose={choose} disabled={false} + separator /> , @@ -63,11 +64,13 @@ it("waits silently for fresh permissions and preserves the last-owner boundary", await waitFor(() => expect(lifecycle.load).toHaveBeenCalledOnce()); expect(screen.queryByText("Checking channel permissions…")).toBeNull(); expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(screen.queryByRole("separator")).toBeNull(); expect(choose).not.toHaveBeenCalled(); gate.resolve(settings); const remove = await screen.findByRole("menuitem", { name: "Delete channel", }); + expect(screen.getAllByRole("separator")).toHaveLength(1); expect(screen.queryByRole("menuitem", { name: /^Leave channel/ })).toBeNull(); expect( screen.queryByText("Transfer ownership before leaving the channel."), @@ -103,6 +106,7 @@ it.each([ lifecycle={lifecycle} choose={choose} disabled={false} + separator /> , @@ -129,6 +133,7 @@ it("failed permission reads offer retry rather than stale destructive actions", lifecycle={lifecycle} choose={() => {}} disabled={false} + separator /> , @@ -139,6 +144,7 @@ it("failed permission reads offer retry rather than stale destructive actions", expect( screen.queryByRole("menuitem", { name: "Archive channel" }), ).toBeNull(); + expect(screen.getAllByRole("separator")).toHaveLength(1); const retry = deferred(); lifecycle.load.mockReturnValueOnce(retry.promise); await user.click( @@ -146,6 +152,7 @@ it("failed permission reads offer retry rather than stale destructive actions", ); await waitFor(() => expect(lifecycle.load).toHaveBeenCalledTimes(2)); expect(screen.queryAllByRole("menuitem")).toHaveLength(0); + expect(screen.queryByRole("separator")).toBeNull(); expect(screen.queryByRole("alert")).toBeNull(); retry.resolve(settings); expect( @@ -153,6 +160,58 @@ it("failed permission reads offer retry rather than stale destructive actions", name: "Archive channel", }), ).toBeDefined(); + expect(screen.getAllByRole("separator")).toHaveLength(1); +}); +it.each([ + { separator: false, allowed: true }, + { separator: true, allowed: false }, +])("omits an orphan separator: %j", async ({ separator, allowed }) => { + const lifecycle = capability(); + const gate = deferred(); + lifecycle.load.mockReturnValueOnce(gate.promise); + render( + + + {}} + disabled={false} + separator={separator} + /> + + , + ); + await waitFor(() => expect(lifecycle.load).toHaveBeenCalledOnce()); + await act(async () => { + gate.resolve({ ...settings, canArchive: allowed, canDelete: allowed }); + await gate.promise; + }); + expect(screen.queryAllByRole("menuitem")).toHaveLength(allowed ? 2 : 0); + expect(screen.queryByRole("separator")).toBeNull(); +}); +it("keeps the unavailable section separated without loading permissions", async () => { + const lifecycle = { ...capability(), available: false }; + render( + + + {}} + disabled={false} + separator + /> + + , + ); + expect( + await screen.findByRole("menuitem", { + name: "Channel actions unavailable on this connection", + }), + ).toBeDefined(); + expect(screen.getAllByRole("separator")).toHaveLength(1); + expect(lifecycle.load).not.toHaveBeenCalled(); }); 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. diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx index aa030a2ec..c6a332eab 100644 --- a/src/bundled/channels/ChannelLifecycleMenu.tsx +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { MenuItem } from "../../shared/design-system/ui/Menu"; +import { MenuItem, MenuSeparator } from "../../shared/design-system/ui/Menu"; import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle"; import type { ChannelLifecycleAction, @@ -12,11 +12,13 @@ export function ChannelLifecycleMenu({ lifecycle, choose, disabled, + separator, }: { channelId: string; lifecycle: ChannelLifecycleCapability; choose(action: ChannelLifecycleAction): void; disabled: boolean; + separator: boolean; }) { const [state, setState] = useState(); const [failed, setFailed] = useState(false); @@ -39,13 +41,17 @@ export function ChannelLifecycleMenu({ }, [channelId, lifecycle, retry]); if (!lifecycle.available) return ( - - Channel actions unavailable on this connection - + <> + {separator && } + + Channel actions unavailable on this connection + + ); if (failed) return ( <> + {separator && }

Channel actions unavailable

); - if (!state) return null; + if ( + !state || + !(state.canHide || state.canArchive || state.canDelete || state.canLeave) + ) + return null; return ( <> + {separator && } {state.canHide ? ( choose("hide")}> Hide conversation diff --git a/src/features/relay/channel-lifecycle.test.ts b/src/features/relay/channel-lifecycle.test.ts index 3804fcd35..38f9aba84 100644 --- a/src/features/relay/channel-lifecycle.test.ts +++ b/src/features/relay/channel-lifecycle.test.ts @@ -56,7 +56,15 @@ function harness(role = "owner", type = "stream", owners = 1) { const read = vi.fn(async (filters: readonly ReadFilter[]) => { if (filters[0]?.kinds?.[0] === 30622) return visible; return events.filter((event) => - filters.some((filter) => filter.kinds?.includes(event.kind)), + filters.some( + (filter) => + filter.kinds?.includes(event.kind) && + (!filter["#p"] || + event.tags.some( + ([key, value]) => + key === "p" && filter["#p"]?.includes(value ?? ""), + )), + ), ); }); const sign = vi.fn(async (event: EventTemplate) => @@ -71,7 +79,16 @@ function harness(role = "owner", type = "stream", owners = 1) { if (event.kind === 9008) events = events.filter((event) => event.kind !== 39000); if (event.kind === 9022) - events = events.filter((event) => event.kind !== 39002); + events = events.map((entry) => + entry.kind === 39002 + ? record( + 39002, + entry.tags.filter( + ([key, value]) => key !== "p" || value !== viewer, + ), + ) + : entry, + ); if (event.kind === 41012) visible = [ record(30622, [ @@ -258,6 +275,23 @@ it.each(["archive", "delete", "leave", "hide"] as const)( expect(h.owner.capability.snapshot().hidden).toEqual([id]); expect(h.removed).not.toHaveBeenCalled(); } + if (action === "leave") { + expect( + h.getEvents().find((event) => event.kind === 39002)?.tags, + ).toEqual([ + ["d", id], + ["p", other, "", "owner"], + ]); + expect(h.read.mock.calls.at(-1)?.[0]).toEqual([ + { + kinds: [39002], + authors: [relayAuthor], + "#d": [id], + "#p": [viewer], + limit: 1, + }, + ]); + } h.owner.dispose(); } finally { clock.mockRestore(); diff --git a/src/features/relay/transport.ts b/src/features/relay/transport.ts index 96cc56060..c5fa0551e 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -617,7 +617,7 @@ export async function connectBrokerTransport( { method: "POST", credentials: "same-origin", - headers: { "Content-Type": "application/json" }, + headers: publicationHeaders(), body: JSON.stringify(event), signal, }, diff --git a/tests/browser/channel-lifecycle.spec.mjs b/tests/browser/channel-lifecycle.spec.mjs index fbb818ad0..ce663c43b 100644 --- a/tests/browser/channel-lifecycle.spec.mjs +++ b/tests/browser/channel-lifecycle.spec.mjs @@ -42,14 +42,50 @@ test("archive confirmation returns focus on cancel and navigates after confirmed exact: true, }), ).toBeVisible(); - await row.focus(); - await page.keyboard.press("Shift+F10"); + // Hold the real permission request: the composed row menu must have no + // orphan divider or unchecked actions. + let release; + let intercepted; + const held = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + intercepted = resolve; + }); + const permissionRoute = async (route) => { + const filters = route.request().postDataJSON(); + if ( + !filters.some( + (filter) => + filter.kinds?.includes(39001) && + filter["#d"]?.includes("11111111-1111-4111-8111-111111111111"), + ) + ) + return route.continue(); + intercepted(); + await held; + await route.continue(); + }; + await page.route("**/api/relay/**/query", permissionRoute); const menu = page.getByRole("menu", { name: "Actions for Lifecycle channel", }); + try { + await row.focus(); + await page.keyboard.press("Shift+F10"); + await seen; + await expect(menu).toBeVisible(); + await expect(menu.getByRole("separator")).toHaveCount(0); + await expect(menu.getByRole("menuitem")).toHaveCount(0); + await expect(menu.getByRole("menuitemradio")).toHaveCount(0); + } finally { + release(); + } await expect( menu.getByRole("menuitem", { name: "Archive channel", exact: true }), ).toBeVisible(); + await expect(menu.getByRole("separator")).toHaveCount(0); + await page.unroute("**/api/relay/**/query", permissionRoute); await expect( menu.getByRole("menuitem", { name: /^Leave channel/ }), ).toHaveCount(0); From 7dc68260a02c7ad35f5ff849f781106e7b16b10e Mon Sep 17 00:00:00 2001 From: Carl Date: Wed, 23 Sep 2026 23:47:28 -0700 Subject: [PATCH 06/11] fix(channels): use shared lifecycle dialog controls Reuse the shared confirmation input and destructive button recipe instead of overriding control styles and consuming direct palette tokens. Signed-off-by: Carl --- .../channels/ChannelLifecycleDialog.module.css | 12 ------------ src/bundled/channels/ChannelLifecycleDialog.tsx | 8 +++++--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/bundled/channels/ChannelLifecycleDialog.module.css b/src/bundled/channels/ChannelLifecycleDialog.module.css index 28f0dfe2a..36d46379a 100644 --- a/src/bundled/channels/ChannelLifecycleDialog.module.css +++ b/src/bundled/channels/ChannelLifecycleDialog.module.css @@ -33,11 +33,6 @@ } .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; @@ -46,10 +41,3 @@ 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 index bf5a65112..a31c06e29 100644 --- a/src/bundled/channels/ChannelLifecycleDialog.tsx +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { Button } from "../../shared/design-system/ui/Button"; +import { Input } from "../../shared/design-system/ui/Input"; import { ChannelLifecycleUnconfirmed, type ChannelLifecycleCapability, @@ -98,9 +99,10 @@ export function ChannelLifecycleDialog({

{copy[action].detail}

{action === "delete" && ( -