diff --git a/dev/relay-broker-api.test.mjs b/dev/relay-broker-api.test.mjs index 2a5e1ed5..c129e047 100644 --- a/dev/relay-broker-api.test.mjs +++ b/dev/relay-broker-api.test.mjs @@ -1318,3 +1318,82 @@ test("message/reaction deletions pass real signing and publication without admit 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 } + : [], + ), + ); + let live; + 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 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.publications).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.publications).toHaveLength(1); + } finally { + live?.dispose(); + await h.close(); + } +}); diff --git a/dev/relay-broker.mjs b/dev/relay-broker.mjs index eec989c0..64ee97a9 100644 --- a/dev/relay-broker.mjs +++ b/dev/relay-broker.mjs @@ -1,4 +1,5 @@ import { prepareMedia } from "./media-preparation.mjs"; +import { validateLifecycleTemplate } from "../src/features/relay/channel-lifecycle-protocol.ts"; import { prepareChannelKit, decodeChannelKit, @@ -684,6 +685,7 @@ export function relayBrokerPlugin({ ...WORKFLOW_KINDS, ...((await getAuthority(relay)).channelCreation ? [9007] : []), ], + channelLifecycle: true, workflowReads: true, attachmentUploads: true, sidebarPreferences: true, @@ -1137,6 +1139,8 @@ export function relayBrokerPlugin({ "/api/relay/query", "/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", @@ -1304,10 +1308,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 ebcff234..e7e3ed19 100644 --- a/docs/channels.md +++ b/docs/channels.md @@ -80,6 +80,55 @@ palette; legacy sidebar filters are ignored. The saved-groups browser regression records every visible return frame and holds the redundant decode path, so eventual restoration cannot conceal a fallback-group/scroll jump. +## Channel lifecycle + +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; +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 +(`["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. Pending +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 +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. + +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 +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 visible sidebar-row focus fallback. + ## Performance and correctness carried from Astra The port retains the prepared-store implementation and its behavior tests: diff --git a/src/bundled/channels/ChannelLifecycleDialog.module.css b/src/bundled/channels/ChannelLifecycleDialog.module.css new file mode 100644 index 00000000..36d46379 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.module.css @@ -0,0 +1,43 @@ +.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%; +} +.actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: var(--space-2); + margin-top: var(--space-6); +} diff --git a/src/bundled/channels/ChannelLifecycleDialog.tsx b/src/bundled/channels/ChannelLifecycleDialog.tsx new file mode 100644 index 00000000..a31c06e2 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleDialog.tsx @@ -0,0 +1,139 @@ +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, +} from "../../features/relay/channel-lifecycle"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; +import styles from "./ChannelLifecycleDialog.module.css"; + +const copy = { + archive: { + title: "Archive channel", + detail: + "Archive this channel for everyone and remove it from the sidebar. Messages are retained. A channel administrator can unarchive it from another supported client.", + }, + delete: { + title: "Delete channel", + detail: + "Delete this channel for everyone. You cannot undo this action from Buzz.", + }, + leave: { + title: "Leave channel", + detail: + "Leave this channel and remove it from your sidebar. You may need an invitation to rejoin a private channel.", + }, + hide: { + title: "Hide conversation", + detail: + "Hide this conversation from your sidebar only. Messages and membership are kept; other participants are not removed.", + }, +} as const; + +export function ChannelLifecycleDialog({ + channelId, + channelName, + action, + lifecycle, + close, + completed, +}: { + channelId: string; + channelName: string; + action: ChannelLifecycleAction; + lifecycle: ChannelLifecycleCapability; + close(): void; + completed(): void; +}) { + const dialog = useRef(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); + const [refreshRequired, setRefreshRequired] = useState(false); + const [confirmation, setConfirmation] = useState(""); + const operation = useRef(undefined); + useEffect(() => { + dialog.current?.showModal(); + return () => { + operation.current?.abort(); + }; + }, []); + const submit = async () => { + if ( + operation.current || + refreshRequired || + (action === "delete" && confirmation !== channelName) + ) + return; + const controller = new AbortController(); + operation.current = controller; + setBusy(true); + setError(""); + try { + await lifecycle.run(action, channelId, controller.signal); + if (!controller.signal.aborted) completed(); + } catch (error) { + if (!controller.signal.aborted) { + setError(error instanceof Error ? error.message : String(error)); + setRefreshRequired(error instanceof ChannelLifecycleUnconfirmed); + } + } finally { + operation.current = undefined; + if (!controller.signal.aborted) setBusy(false); + } + }; + return ( + { + event.preventDefault(); + if (!busy) close(); + }} + > +

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

+

{copy[action].detail}

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

{error}

} + {busy && ( +

+ Checking permissions and waiting for relay confirmation… +

+ )} +
+ + +
+
+ ); +} diff --git a/src/bundled/channels/ChannelLifecycleMenu.test.tsx b/src/bundled/channels/ChannelLifecycleMenu.test.tsx new file mode 100644 index 00000000..ab121375 --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.test.tsx @@ -0,0 +1,342 @@ +// @vitest-environment jsdom +import { afterEach, expect, it, vi } from "vitest"; +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 { + 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("waits silently for fresh permissions and preserves 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( + + + + + , + ); + 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."), + ).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(); + lifecycle.load.mockRejectedValueOnce( + new Error("Malformed channel membership state"), + ); + render( + + + {}} + disabled={false} + separator + /> + + , + ); + expect((await screen.findByRole("alert")).textContent).toBe( + "Channel actions unavailable", + ); + 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( + screen.getByRole("menuitem", { name: "Retry channel permissions" }), + ); + 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( + await screen.findByRole("menuitem", { + 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. + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const user = userEvent.setup(); + const lifecycle = capability(); + const completed = vi.fn(); + const close = vi.fn(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => + gate.promise.then(() => { + throw new Error("relay rejected"); + }), + ); + render( + , + ); + const confirm = screen.getByRole("button", { + name: "Delete channel", + }) as HTMLButtonElement; + expect(confirm.disabled).toBe(true); + await user.type( + screen.getByRole("textbox", { name: "Channel name confirmation" }), + "Fixture", + ); + await user.click(confirm); + await waitFor(() => expect(lifecycle.run).toHaveBeenCalledOnce()); + expect(confirm.disabled).toBe(true); + expect( + (screen.getByRole("button", { name: "Cancel" }) as HTMLButtonElement) + .disabled, + ).toBe(true); + gate.resolve(); + expect((await screen.findByRole("alert")).textContent).toBe("relay rejected"); + expect(completed).not.toHaveBeenCalled(); + expect(confirm.disabled).toBe(false); + await user.click(confirm); + await waitFor(() => expect(completed).toHaveBeenCalledOnce()); +}); + +it.each(["leave", "hide", "archive"] as const)( + "%s requires confirmation and ignores completion after unmount", + async (action) => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + const gate = deferred(); + lifecycle.run.mockImplementationOnce(() => gate.promise); + const completed = vi.fn(); + const user = userEvent.setup(); + const view = render( + {}} + completed={completed} + />, + ); + expect(lifecycle.run).not.toHaveBeenCalled(); + const label = { + leave: "Leave channel", + hide: "Hide conversation", + archive: "Archive channel", + }[action]; + await user.click(screen.getByRole("button", { name: label })); + expect(lifecycle.run).toHaveBeenCalledWith( + action, + "id", + expect.any(AbortSignal), + ); + const signal = vi.mocked(lifecycle.run).mock.calls[0]?.[2]; + view.unmount(); + expect(signal?.aborted).toBe(true); + gate.resolve(); + await gate.promise; + expect(completed).not.toHaveBeenCalled(); + }, +); +it("uncertain delivery keeps the dialog recoverable without offering blind resubmission", async () => { + HTMLDialogElement.prototype.showModal = vi.fn(function ( + this: HTMLDialogElement, + ) { + this.setAttribute("open", ""); + }); + const lifecycle = capability(); + lifecycle.run.mockRejectedValueOnce( + new ChannelLifecycleUnconfirmed("connection lost"), + ); + const user = userEvent.setup(); + const close = vi.fn(); + render( + {}} + />, + ); + const confirm = screen.getByRole("button", { + name: "Leave channel", + }) as HTMLButtonElement; + await user.click(confirm); + expect((await screen.findByRole("alert")).textContent).toContain( + "may have taken effect", + ); + expect(confirm.disabled).toBe(true); + await user.click(screen.getByRole("button", { name: "Cancel" })); + expect(close).toHaveBeenCalledOnce(); + expect(lifecycle.run).toHaveBeenCalledOnce(); +}); diff --git a/src/bundled/channels/ChannelLifecycleMenu.tsx b/src/bundled/channels/ChannelLifecycleMenu.tsx new file mode 100644 index 00000000..c6a332ea --- /dev/null +++ b/src/bundled/channels/ChannelLifecycleMenu.tsx @@ -0,0 +1,98 @@ +import { useEffect, useState } from "react"; +import { MenuItem, MenuSeparator } 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, + separator, +}: { + channelId: string; + lifecycle: ChannelLifecycleCapability; + choose(action: ChannelLifecycleAction): void; + disabled: boolean; + separator: boolean; +}) { + const [state, setState] = 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); + setFailed(false); + void lifecycle.load(channelId, controller.signal).then( + (settings) => { + if (!controller.signal.aborted) setState(settings); + }, + () => { + if (!controller.signal.aborted) setFailed(true); + }, + ); + return () => controller.abort(); + }, [channelId, lifecycle, retry]); + if (!lifecycle.available) + return ( + <> + {separator && } + + Channel actions unavailable on this connection + + + ); + if (failed) + return ( + <> + {separator && } +

Channel actions unavailable

+ setRetry((value) => value + 1)} + > + Retry channel permissions + + + ); + if ( + !state || + !(state.canHide || state.canArchive || state.canDelete || state.canLeave) + ) + return null; + return ( + <> + {separator && } + {state.canHide ? ( + choose("hide")}> + Hide conversation + + ) : ( + <> + {state.canArchive && ( + choose("archive")}> + Archive channel + + )} + {state.canDelete && ( + choose("delete")}> + Delete channel + + )} + {state.canLeave && ( + choose("leave")}> + Leave channel + + )} + + )} + + ); +} diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index c587e2d6..023a47a4 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -73,6 +73,7 @@ it("keeps live unread updates and uses replacement session callbacks across row onSelect, onNewSession: vi.fn(), onOpenThread: vi.fn(), + onLifecycle: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: "Alpha" }); @@ -149,6 +150,7 @@ it("renders one-to-one DM avatars from supplied profiles only, through the media onSelect: vi.fn(), onNewSession: vi.fn(), onOpenThread: vi.fn(), + onLifecycle: vi.fn(), }; const view = render(); const row = screen.getByRole("button", { name: /^Alice$/ }); diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 633c7982..b25b1eb8 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -1,3 +1,5 @@ +import { ChannelLifecycleMenu } from "./ChannelLifecycleMenu"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { memo } from "react"; import { Avatar } from "../../shared/design-system/ui/Avatar"; import type { ChannelSummary, Profile } from "../../features/relay/contracts"; @@ -29,6 +31,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onNewSession, onOpenThread, onHideDm, + onLifecycle, }: { channel: ChannelSummary; profile?: Profile | undefined; @@ -45,12 +48,29 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem({ onNewSession: (id: string) => void; onOpenThread: (channelId: string, rootId: string) => void; onHideDm?: (id: string) => void; + onLifecycle: ( + channel: ChannelSummary, + action: ChannelLifecycleAction, + ) => void; }) { const Icon = channel.channelType === "dm" ? ChatCircleIcon : channelIcon(channel); return ( ( + onLifecycle(channel, action)} + /> + ) + : undefined + } icon={ channel.channelType === "dm" && channel.participants?.length === 1 ? ( void; onNewSession: (id: string) => void; onHideDm?: (id: string) => void; + extraActions?: ((separator: boolean) => ReactNode) | undefined; }) { const starting = useRef(false); + const [moreOpen, setMoreOpen] = useState(false); + const [contextOpen, setContextOpen] = useState(false); + const [contextAnchor, setContextAnchor] = useState(); const childrenId = useId(); const hasChildren = draft || sessions.length > 0; const Chevron = collapsed ? CaretRightIcon : CaretDownIcon; @@ -78,8 +94,26 @@ export function ChannelSidebarRow({ trailing={badge && {badge}} /> ); + const trigger = extraActions ? ( + setContextAnchor(undefined)} + onKeyDown={(event) => { + if ( + event.key === "ContextMenu" || + (event.shiftKey && event.key === "F10") + ) { + event.preventDefault(); + setContextAnchor(event.currentTarget); + setContextOpen(true); + } + }} + /> + ) : ( + selectButton + ); return ( - <> +
)}
- {wrapSelect ? wrapSelect(selectButton) : selectButton} + {wrapSelect ? wrapSelect(trigger) : trigger}
- {canParent && ( + {(canParent || extraActions) && ( { + setMoreOpen(open); if (open) starting.current = false; }} > @@ -151,15 +186,18 @@ export function ChannelSidebarRow({ } aria-label={`${channel.name} options`} > - { - starting.current = true; - onNewSession(channel.id); - }} - > - New session - + {canParent && ( + { + starting.current = true; + onNewSession(channel.id); + }} + > + New session + + )} + {moreOpen && extraActions?.(canParent)} @@ -195,6 +233,19 @@ export function ChannelSidebarRow({ )}
+ {extraActions && ( + + document.querySelector( + `button[data-channel-id="${CSS.escape(channel.id)}"]`, + ) ?? false + } + > + {contextOpen && extraActions(false)} + + )} - +
); } diff --git a/src/bundled/channels/ChannelsPage.tsx b/src/bundled/channels/ChannelsPage.tsx index c8d9a86f..4712b0ac 100644 --- a/src/bundled/channels/ChannelsPage.tsx +++ b/src/bundled/channels/ChannelsPage.tsx @@ -1,3 +1,5 @@ +import { ChannelLifecycleDialog } from "./ChannelLifecycleDialog"; +import type { ChannelLifecycleAction } from "../../features/relay/channel-lifecycle-protocol"; import { personalGroups } from "../../features/channel-templates/setup"; import type { TemplateProviders } from "../../features/channel-templates/provider"; import { OwnedContribution } from "../../plugins/OwnedContribution"; @@ -66,7 +68,10 @@ import { MessageComposer } from "../../features/messages/MessageComposer"; import { ChannelTimeline } from "../../features/messages/ChannelTimeline"; import { ThreadPanel } from "../../features/messages/ThreadPanel"; import { MediaReviewViewer } from "../../features/messages/MediaReviewViewer"; -import type { Attachment } from "../../features/relay/contracts"; +import type { + Attachment, + ChannelSummary, +} from "../../features/relay/contracts"; import { readView, writeView } from "../../shared/view-state"; import { useChannelLabels } from "./useChannelLabels"; import { useComposerSent } from "./useComposerSent"; @@ -232,6 +237,30 @@ function ChannelWorkspace({ const [initialGroup, setInitialGroup] = useState(""); const [kitError, setKitError] = useState(""); 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 chooseLifecycle = useCallback( + (channel: ChannelSummary, action: ChannelLifecycleAction) => { + // Let the menu restore focus before opening the confirmation dialog. + requestAnimationFrame(() => { + if (mounted.current) setLifecycleDialog({ channel, action }); + }); + }, + [], + ); useEffect(() => { void queries.emoji.ensure(); }, [queries]); @@ -306,11 +335,32 @@ function ChannelWorkspace({ scope, list.status === "ready" && preferences.status !== "loading", ); - const { channels, profiles: dmProfiles } = useChannelLabels( + const { channels: labelled, profiles: dmProfiles } = useChannelLabels( list.channels, queries.profiles, queries.names, ); + const channels = useMemo( + () => labelled.filter((channel) => !channel.archived), + [labelled], + ); + useLayoutEffect(() => { + if (!lifecycleFocus.current || lifecycleDialog) return; + const id = lifecycleFocus.current; + lifecycleFocus.current = undefined; + const rows = [ + ...(sidebar.list.current?.querySelectorAll( + "[data-channel-id]", + ) ?? []), + ]; + const target = + rows.find( + (row) => row.dataset.channelId === id && row.getClientRects().length, + ) ?? rows.find((row) => row.getClientRects().length); + if (target) target.focus({ preventScroll: true }); + else + sidebar.list.current?.closest("aside")?.querySelector("input")?.focus(); + }, [lifecycleDialog, sidebar.list]); const childSessions = useRef(new Map()); const childrenByParent = useMemo(() => { const children = new Map(); @@ -889,7 +939,7 @@ function ChannelWorkspace({ starred: preferences.data?.starred ?? [], } : preferences.data, - hiddenDms.hiddenIds, + new Set([...hiddenDms.hiddenIds, ...dmVisibility.hidden]), ); return (
+ {lifecycleDialog && ( + { + lifecycleFocus.current = lifecycleDialog.channel.id; + setLifecycleDialog(undefined); + }} + completed={() => { + const id = lifecycleDialog.channel.id; + lifecycleFocus.current = id; + setLifecycleDialog(undefined); + if ( + current?.id === id || + requestedChannel === id || + selected === id + ) { + const next = sections + .flatMap((section) => section.rows) + .find((channel) => channel.id !== id); + if (next) select(next.id); + else { + setSelected(undefined); + writeView(scope, "selected-channel", undefined); + void navigator?.open({ + version: 1, + kind: "page", + pluginId: "buzz.channels", + pageId: "channels", + }); + } + } + }} + /> + )}
{kitState.status === "error" && ( @@ -951,6 +1038,14 @@ function ChannelWorkspace({ )}
)} + {dmVisibility.status === "error" && ( +
+ Hidden conversations could not be refreshed.{" "} + +
+ )} {sections.map((section) => { const showsCreateChannel = isChannelSectionKey(section.key); @@ -1043,6 +1138,7 @@ function ChannelWorkspace({ onNewSession={startSession} onOpenThread={openActivityThread} onHideDm={hiddenDms.hide} + onLifecycle={chooseLifecycle} /> ); })} diff --git a/src/features/relay/channel-lifecycle-protocol.ts b/src/features/relay/channel-lifecycle-protocol.ts new file mode 100644 index 00000000..0eceeb91 --- /dev/null +++ b/src/features/relay/channel-lifecycle-protocol.ts @@ -0,0 +1,157 @@ +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 ( + // 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) + ) + 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 00000000..38f9aba8 --- /dev/null +++ b/src/features/relay/channel-lifecycle.test.ts @@ -0,0 +1,578 @@ +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, "", role], + ["p", other, "", owners > 1 ? "owner" : "member"], + ]), + ]; + 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) && + (!filter["#p"] || + event.tags.some( + ([key, value]) => + key === "p" && filter["#p"]?.includes(value ?? ""), + )), + ), + ); + }); + 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.map((entry) => + entry.kind === 39002 + ? record( + 39002, + entry.tags.filter( + ([key, value]) => key !== "p" || value !== viewer, + ), + ) + : entry, + ); + 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.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([ + 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(); + } + 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(); + } + }, +); + +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 00000000..cf96ad6b --- /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 f1047aba..d16013ca 100644 --- a/src/features/relay/session.ts +++ b/src/features/relay/session.ts @@ -12,6 +12,7 @@ import { createPresence } from "../presence/presence"; 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 { @@ -309,6 +310,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. @@ -547,6 +549,16 @@ export function createRelaySession( ); canAccess = channels.canAccess; retainedChannelEvent = channels.retainedEvent; + 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 ?? "", @@ -1198,6 +1210,7 @@ export function createRelaySession( agentLibrary: agentLibrary.queries, agentChoices, workflows: workflows.capability, + channelLifecycle: lifecycle.capability, agentActivity: activity.queries, archives: archives.queries, media: (url: string, size?: "small") => transport?.media(url, size), @@ -1688,6 +1701,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(); @@ -1710,6 +1724,7 @@ export function createRelaySession( activity.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 deb29bda..68bd2920 100644 --- a/src/features/relay/transport.ts +++ b/src/features/relay/transport.ts @@ -53,6 +53,8 @@ export interface RelayWriter { export interface ReadTransport { readonly uploadAttachment?: AttachmentUpload; 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. */ @@ -228,6 +230,7 @@ export async function connectBrokerTransport( writeKinds?: number[]; workflowReads?: boolean; attachmentUploads?: boolean; + channelLifecycle?: boolean; relayUrl?: string; live?: boolean; presence?: boolean; @@ -479,6 +482,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: publicationHeaders(), + 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 00000000..ce663c43 --- /dev/null +++ b/tests/browser/channel-lifecycle.spec.mjs @@ -0,0 +1,247 @@ +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(); + // 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); + 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", exact: true }) + .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", exact: true }) + .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", exact: true }), + ).toBeVisible(); + await expect( + menu.getByRole("menuitem", { name: "Delete channel", exact: true }), + ).toHaveCount(0); + await expect( + menu.getByRole("menuitem", { name: "Leave channel", exact: true }), + ).toHaveCount(0); + await menu + .getByRole("menuitem", { name: "Hide conversation", exact: true }) + .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", exact: true }) + .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 d149301e..90a46778 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) { @@ -488,39 +513,89 @@ 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)) .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)) 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()]; @@ -814,13 +889,30 @@ export const test = base.extend({ answer, report, pending, - ...(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/navigation-sidebar.spec.mjs b/tests/browser/navigation-sidebar.spec.mjs index c068837b..82844b3b 100644 --- a/tests/browser/navigation-sidebar.spec.mjs +++ b/tests/browser/navigation-sidebar.spec.mjs @@ -378,9 +378,19 @@ test("session actions follow the Sessions plugin availability", async ({ await button(page, "Messages").first().click(); await button(page, "Alpha").hover(); + // The row now also owns lifecycle actions, independent of the Sessions plugin. + // Assert the original contract on the action, not the shared menu trigger. + await page.getByRole("button", { name: "More options for Alpha" }).click(); + const menu = page.getByRole("menu", { name: "More options for Alpha" }); + await expect(menu).toBeVisible(); + await expect(menu.getByRole("menuitem", { name: "New session" })).toHaveCount( + 0, + ); await expect( - page.getByRole("button", { name: "More options for Alpha" }), - ).toHaveCount(0); + menu.getByRole("menuitem", { + name: "Channel actions unavailable on this connection", + }), + ).toBeVisible(); }); test("channel navigation preserves sidebar DOM, group state and scroll", async ({ diff --git a/tests/browser/policy-relay.mjs b/tests/browser/policy-relay.mjs index 4de74b6a..3b5f656a 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) { // The read-only sidebar projection reads the two exact coordinates. expect(filters).toHaveLength(2);