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

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

+

{copy[action].detail}

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

{error}

} + {busy && ( +

+ Checking permissions and waiting for relay confirmation… +

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

{error}

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

{state.leaveReason}

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