Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/channels.md
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,15 @@ 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.

Channel Settings also offers **Leave channel** in its tools area after a fresh
lifecycle permission check. Forbidden Leave is omitted, just as in the row menu;
failed checks offer retry and unsupported connections explain unavailability.
DMs, sessions and read-only views have no channel Leave entry. This control hands
off to the same persistent sidebar confirmation/navigation owner, so confirmed
membership removal can unmount Settings without cancelling its completion.
Cancellation returns focus to the Settings Leave button (or the sidebar fallback
if that entry has gone away). Metadata and member-role editing remain separate.

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
Expand Down
161 changes: 161 additions & 0 deletions src/bundled/channels/ChannelLeaveButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
// @vitest-environment jsdom
import "@testing-library/jest-dom/vitest";
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, expect, it, vi } from "vitest";
import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle";
import type { ChannelLifecycleSettings } from "../../features/relay/channel-lifecycle-protocol";
import { ChannelLeaveButton } from "./ChannelLeaveButton";

afterEach(cleanup);
const settings: ChannelLifecycleSettings = {
channelId: "alpha",
channelType: "stream",
canLeave: true,
canArchive: false,
canDelete: false,
canHide: false,
};
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
function capability() {
return {
available: true,
load: vi.fn<ChannelLifecycleCapability["load"]>(async () => settings),
run: vi.fn<ChannelLifecycleCapability["run"]>(),
snapshot: () => ({ status: "ready", hidden: [] }),
subscribe: () => () => {},
refreshVisibility: async () => {},
} satisfies ChannelLifecycleCapability;
}
it("waits for fresh permission then hands the trigger to the existing confirmation, without writing", async () => {
const lifecycle = capability();
const gate = deferred<ChannelLifecycleSettings>();
lifecycle.load.mockReturnValueOnce(gate.promise);
const choose = vi.fn();
render(
<ChannelLeaveButton
channelId="alpha"
lifecycle={lifecycle}
choose={choose}
/>,
);
await waitFor(() => expect(lifecycle.load).toHaveBeenCalledOnce());
expect(screen.queryByRole("button")).toBeNull();
await act(async () => gate.resolve(settings));
const button = screen.getByRole("button", {
name: "Leave channel",
});
const user = userEvent.setup();
button.focus();
await user.keyboard("{Enter}");
expect(choose).toHaveBeenCalledWith(button);
expect(lifecycle.run).not.toHaveBeenCalled();
});
it("omits forbidden Leave rather than an ownership-transfer explanation", async () => {
const lifecycle = capability();
lifecycle.load.mockResolvedValue({
...settings,
canLeave: false,
leaveReason: "Transfer ownership before leaving the channel.",
});
const mounted = render(
<ChannelLeaveButton
channelId="alpha"
lifecycle={lifecycle}
choose={vi.fn()}
/>,
);
await act(async () => {});
expect(mounted.container).toBeEmptyDOMElement();
});
it("keeps unavailable permission checks distinct from forbidden and offers explicit retry", async () => {
const lifecycle = capability();
lifecycle.load.mockRejectedValueOnce(new Error("Malformed state"));
render(
<ChannelLeaveButton
channelId="alpha"
lifecycle={lifecycle}
choose={vi.fn()}
/>,
);
expect(await screen.findByRole("alert")).toHaveTextContent(
"Channel actions unavailable",
);
const gate = deferred<ChannelLifecycleSettings>();
lifecycle.load.mockReturnValueOnce(gate.promise);
await userEvent
.setup()
.click(screen.getByRole("button", { name: "Retry channel permissions" }));
await waitFor(() => expect(lifecycle.load).toHaveBeenCalledTimes(2));
expect(screen.queryByRole("button")).toBeNull();
await act(async () => gate.resolve(settings));
expect(
await screen.findByRole("button", { name: "Leave channel" }),
).toBeEnabled();
});
it("explains an unsupported host without starting a lookup or write", () => {
const lifecycle = { ...capability(), available: false };
render(
<ChannelLeaveButton
channelId="alpha"
lifecycle={lifecycle}
choose={vi.fn()}
/>,
);
expect(
screen.getByText("Channel actions unavailable on this connection"),
).toBeVisible();
expect(lifecycle.load).not.toHaveBeenCalled();
expect(lifecycle.run).not.toHaveBeenCalled();
});
it("fences permissions from a replaced channel, capability, or unmounted pane", async () => {
const lifecycle = capability();
const old = deferred<ChannelLifecycleSettings>();
const next = deferred<ChannelLifecycleSettings>();
lifecycle.load
.mockReturnValueOnce(old.promise)
.mockReturnValueOnce(next.promise);
const choose = vi.fn();
const mounted = render(
<ChannelLeaveButton
channelId="alpha"
lifecycle={lifecycle}
choose={choose}
/>,
);
const signal = lifecycle.load.mock.calls[0]?.[1];
mounted.rerender(
<ChannelLeaveButton
channelId="beta"
lifecycle={lifecycle}
choose={choose}
/>,
);
expect(signal?.aborted).toBe(true);
await act(async () => old.resolve(settings));
expect(screen.queryByRole("button")).toBeNull();
await act(async () => next.resolve({ ...settings, channelId: "beta" }));
expect(screen.getByRole("button", { name: "Leave channel" })).toBeEnabled();
const replacement = capability();
const held = deferred<ChannelLifecycleSettings>();
replacement.load.mockReturnValueOnce(held.promise);
mounted.rerender(
<ChannelLeaveButton
channelId="beta"
lifecycle={replacement}
choose={choose}
/>,
);
expect(screen.queryByRole("button")).toBeNull();
const pending = replacement.load.mock.calls[0]?.[1];
mounted.unmount();
expect(pending?.aborted).toBe(true);
await act(async () => held.resolve(settings));
expect(choose).not.toHaveBeenCalled();
});
63 changes: 63 additions & 0 deletions src/bundled/channels/ChannelLeaveButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { useEffect, useState } from "react";
import type { ChannelLifecycleCapability } from "../../features/relay/channel-lifecycle";
import { Button } from "../../shared/design-system/ui/Button";
import { SignOutIcon } from "../../shared/design-system/icons";

/** Settings reads permissions only while open; the sidebar owns confirmation. */
export function ChannelLeaveButton({
channelId,
lifecycle,
choose,
}: {
channelId: string;
lifecycle: ChannelLifecycleCapability;
choose(trigger: HTMLElement): void;
}) {
const [state, setState] = useState<{
channelId: string;
lifecycle: ChannelLifecycleCapability;
canLeave?: boolean;
failed?: boolean;
}>();
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);
void lifecycle.load(channelId, controller.signal).then(
({ canLeave }) => {
if (!controller.signal.aborted)
setState({ channelId, lifecycle, canLeave });
},
() => {
if (!controller.signal.aborted)
setState({ channelId, lifecycle, failed: true });
},
);
return () => controller.abort();
}, [channelId, lifecycle, retry]);
if (!lifecycle.available)
return <p>Channel actions unavailable on this connection</p>;
if (state?.channelId !== channelId || state.lifecycle !== lifecycle)
return null;
if (state.failed)
return (
<div>
<p role="alert">Channel actions unavailable</p>
<Button onClick={() => setRetry((value) => value + 1)}>
Retry channel permissions
</Button>
</div>
);
if (!state.canLeave) return null;
return (
<Button
variant="destructive"
onClick={(event) => choose(event.currentTarget)}
>
<SignOutIcon size={16} aria-hidden="true" />
Leave channel
</Button>
);
}
14 changes: 14 additions & 0 deletions src/bundled/channels/ChannelsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Button } from "../../shared/design-system/ui/Button";
import { IconButton } from "../../shared/design-system/ui/IconButton";
import { useChannelPanels } from "./useChannelPanels";
import { ChannelSettingsPanel } from "./ChannelSettingsPanel";
import { ChannelLeaveButton } from "./ChannelLeaveButton";
import type { PageNavigation } from "../../features/navigation/service";
import type { Navigation } from "../../features/navigation/controller";
import {
Expand Down Expand Up @@ -1152,6 +1153,19 @@ function ChannelWorkspace({
/>
)}
{kitError && <p role="alert">{kitError}</p>}
{handoff &&
!current.readOnly &&
current.channelType !== "dm" &&
current.channelType !== "session" && (
<ChannelLeaveButton
key={current.id}
channelId={current.id}
lifecycle={queries.channelLifecycle}
choose={(trigger) =>
handoff.openLifecycle(current, "leave", trigger)
}
/>
)}
</div>
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ afterEach(() => {
});
function fixture() {
let ids = ["existing"];
// Only the roster read is consumed by this UI handoff provider.
// This UI handoff provider consumes only the roster, never a command writer.
const session = () =>
({
channels: { list: () => ({ channels: ids.map((id) => ({ id })) }) },
Expand Down Expand Up @@ -99,6 +99,10 @@ it("resets transient handoffs and rejects retired callbacks on session replaceme
act(() => {
view.result.current?.prepareDm(["peer"]);
view.result.current?.updateDraftParents(() => ["parent"]);
view.result.current.openLifecycle(
{ id: "channel", name: "Channel" },
"leave",
);
});
const retired = view.result.current;
retired.activityThread.current = {
Expand All @@ -108,12 +112,39 @@ it("resets transient handoffs and rejects retired callbacks on session replaceme
};
act(() => h.replace());
expect(view.result.current.preparingDm).toBeUndefined();
expect(view.result.current.lifecycleDialog).toBeUndefined();
expect(view.result.current.activityThread.current).toBeUndefined();
expect(view.result.current.draftParents).toEqual(["parent"]);
act(() => {
retired.prepareDm(["late"]);
retired.updateDraftParents(() => ["late"]);
retired.openLifecycle({ id: "late", name: "Late" }, "leave");
});
expect(view.result.current.preparingDm).toBeUndefined();
expect(view.result.current.draftParents).toEqual(["parent"]);
expect(view.result.current.lifecycleDialog).toBeUndefined();
});

it("hands both entrances to one confirmation and preserves its origin until closed", () => {
const h = fixture();
const view = mount(h.relay);
const channel = { id: "channel", name: "Channel" };
const trigger = document.createElement("button");
act(() => view.result.current.openLifecycle(channel, "leave", trigger));
expect(view.result.current.lifecycleDialog).toEqual({
channel,
action: "leave",
trigger,
});
act(() =>
view.result.current.openLifecycle({ id: "other", name: "Other" }, "delete"),
);
expect(view.result.current.lifecycleDialog?.channel).toBe(channel);
act(() => view.result.current.closeLifecycle());
expect(view.result.current.lifecycleDialog).toBeUndefined();
act(() => view.result.current.openLifecycle(channel, "archive"));
expect(view.result.current.lifecycleDialog).toEqual({
channel,
action: "archive",
});
});
33 changes: 33 additions & 0 deletions src/features/channel-navigation/ChannelNavigationState.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
import { useRelayConnection } from "../relay/react";
import type { RelayData } from "../relay/service";
import type { RelaySession } from "../relay/session";
import type { ChannelSummary } from "../relay/contracts";
import type { ChannelLifecycleAction } from "../relay/channel-lifecycle-protocol";
import { readView, writeView } from "../../shared/view-state";

type PreparingDm = { existing: Set<string>; members: Set<string | undefined> };
Expand All @@ -19,6 +21,13 @@ type State = {
scope: string;
draftParents: string[];
preparingDm: PreparingDm | undefined;
lifecycleDialog:
| {
channel: ChannelSummary;
action: ChannelLifecycleAction;
trigger?: HTMLElement;
}
| undefined;
};
type ActivityThread = {
channelId: string;
Expand All @@ -30,6 +39,12 @@ type Handoff = State & {
updateDraftParents(update: (previous: string[]) => string[]): void;
prepareDm(members: readonly string[]): void;
clearPreparingDm(): void;
openLifecycle(
channel: ChannelSummary,
action: ChannelLifecycleAction,
trigger?: HTMLElement,
): void;
closeLifecycle(): void;
};
const ChannelNavigationContext = createContext<Handoff | undefined>(undefined);
export const useChannelNavigation = () => useContext(ChannelNavigationContext);
Expand Down Expand Up @@ -94,6 +109,23 @@ export function ChannelNavigationProvider({
},
}));
},
openLifecycle(channel, action, trigger) {
update((previous) =>
previous.lifecycleDialog
? previous
: {
...previous,
lifecycleDialog: {
channel,
action,
...(trigger ? { trigger } : {}),
},
},
);
},
closeLifecycle() {
update((previous) => ({ ...previous, lifecycleDialog: undefined }));
},
clearPreparingDm,
}),
[state, connection.session, connection.viewer, update, clearPreparingDm],
Expand All @@ -110,6 +142,7 @@ function restore(session: RelaySession, scope: string): State {
session,
scope,
preparingDm: undefined,
lifecycleDialog: undefined,
draftParents: Array.isArray(saved)
? saved.filter((id): id is string => typeof id === "string")
: [],
Expand Down
Loading
Loading