feat(desktop): add channel management menu actions

Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e <e18f3511303812c437d487ac4bf46ad897dc46946699b18ab2e60bf8ee0ea851@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e
2026-07-13 17:45:20 -07:00
parent f3599f2cd4
commit 4bed899be1
6 changed files with 170 additions and 3 deletions
+11 -1
View File
@@ -106,6 +106,8 @@ export function AppShell() {
const [managedChannelId, setManagedChannelId] = React.useState<string | null>(
null,
);
const [openChannelManagementInEditMode, setOpenChannelManagementInEditMode] =
React.useState(false);
const [searchFocusRequest, setSearchFocusRequest] = React.useState(0);
const [browseDialogType, setBrowseDialogType] =
React.useState<BrowseDialogType>(null);
@@ -637,10 +639,14 @@ export function AppShell() {
markChannelRead,
markChannelUnread,
openCreateChannel: handleOpenCreateChannel,
openChannelManagement: (channelId?: string) => {
openChannelManagement: (
channelId?: string,
options?: { edit?: boolean },
) => {
setManagedChannelId(
typeof channelId === "string" ? channelId : null,
);
setOpenChannelManagementInEditMode(options?.edit === true);
setIsChannelManagementOpen(true);
},
getChannelReadAt,
@@ -906,12 +912,16 @@ export function AppShell() {
channels={channels}
currentPubkey={identityQuery.data?.pubkey}
isChannelManagementOpen={isChannelManagementOpen}
openChannelManagementInEditMode={
openChannelManagementInEditMode
}
onBrowseChannelJoin={handleBrowseChannelJoin}
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
onChannelManagementOpenChange={(open) => {
setIsChannelManagementOpen(open);
if (!open) {
setManagedChannelId(null);
setOpenChannelManagementInEditMode(false);
}
}}
onDeleteActiveChannel={() => {
+4 -1
View File
@@ -16,7 +16,10 @@ type AppShellContextValue = {
) => void;
markChannelUnread: (channelId: string) => void;
openCreateChannel: () => void;
openChannelManagement: (channelId?: string) => void;
openChannelManagement: (
channelId?: string,
options?: { edit?: boolean },
) => void;
// NIP-RS read marker for a channel as a unix-seconds timestamp, or null
// when unknown. Backed by the single AppShell-mounted ReadStateManager so
// every surface (sidebar, home, badges) projects from the same source.
+3
View File
@@ -21,6 +21,7 @@ type AppShellOverlaysProps = {
channels: Channel[];
currentPubkey?: string;
isChannelManagementOpen: boolean;
openChannelManagementInEditMode: boolean;
onBrowseChannelJoin: (channelId: string) => Promise<void>;
onBrowseDialogOpenChange: (open: boolean) => void;
onChannelManagementOpenChange: (open: boolean) => void;
@@ -34,6 +35,7 @@ export function AppShellOverlays({
channels,
currentPubkey,
isChannelManagementOpen,
openChannelManagementInEditMode,
onBrowseChannelJoin,
onBrowseDialogOpenChange,
onChannelManagementOpenChange,
@@ -80,6 +82,7 @@ export function AppShellOverlays({
<ChannelManagementSheet
channel={activeChannel}
currentPubkey={currentPubkey}
initiallyEditing={openChannelManagementInEditMode}
onDeleted={onDeleteActiveChannel}
onOpenChange={onChannelManagementOpenChange}
open={true}
@@ -89,6 +89,7 @@ type ChannelManagementSheetProps = {
channel: Channel | null;
animateSplitEnter?: boolean;
currentPubkey?: string;
initiallyEditing?: boolean;
layout?: "overlay" | "split";
onDeleted?: () => void;
onOpenChange: (open: boolean) => void;
@@ -100,6 +101,7 @@ export function ChannelManagementSheet({
animateSplitEnter = false,
channel,
currentPubkey,
initiallyEditing = false,
layout = "overlay",
onDeleted,
onOpenChange,
@@ -229,8 +231,9 @@ export function ChannelManagementSheet({
setTtlDraft(
detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "",
);
setIsEditDialogOpen(initiallyEditing);
setActiveView("summary");
}, [detail, open]);
}, [detail, initiallyEditing, open]);
if (!channel) {
return null;
@@ -1,4 +1,5 @@
import {
Archive,
Bell,
BellOff,
Check,
@@ -6,11 +7,22 @@ import {
CircleDot,
Copy,
LogOut,
Pencil,
Plus,
Star,
StarOff,
} from "lucide-react";
import { toast } from "sonner";
import { useAppShell } from "@/app/AppShellContext";
import {
useArchiveChannelMutation,
useChannelMembersQuery,
} from "@/features/channels/hooks";
import { useUsersBatchQuery } from "@/features/profile/hooks";
import { ownsAuthorAgent } from "@/features/profile/lib/identity";
import { useIdentityQuery } from "@/shared/api/hooks";
import { normalizePubkey } from "@/shared/lib/pubkey";
import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
import {
ContextMenuIconSlot,
@@ -166,6 +178,37 @@ export function ChannelContextMenuItems({
onCreateSectionForChannel?: (channelId: string) => void;
onLeaveChannel?: (channel: Channel) => void;
}) {
const { openChannelManagement } = useAppShell();
const currentPubkey = useIdentityQuery().data?.pubkey;
const archiveChannelMutation = useArchiveChannelMutation(channel.id);
const membersQuery = useChannelMembersQuery(
channel.id,
channel.channelType !== "dm",
);
const ownerPubkeys =
membersQuery.data
?.filter(
(member) => member.role === "owner" && member.pubkey !== currentPubkey,
)
.map((member) => member.pubkey) ?? [];
const ownerProfilesQuery = useUsersBatchQuery(ownerPubkeys, {
enabled: ownerPubkeys.length > 0,
});
const selfMember = membersQuery.data?.find(
(member) => member.pubkey === currentPubkey,
);
const canManageOwnedAgentChannel = ownerPubkeys.some((pubkey) =>
ownsAuthorAgent(
ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)],
currentPubkey,
),
);
const showManagementActions =
channel.channelType !== "dm" &&
channel.archivedAt === null &&
(selfMember?.role === "owner" ||
selfMember?.role === "admin" ||
canManageOwnedAgentChannel);
const showStar = Boolean(onStarChannel && onUnstarChannel);
const showReadToggle = hasUnread
? Boolean(onMarkChannelRead)
@@ -192,6 +235,42 @@ export function ChannelContextMenuItems({
onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
/>
) : null}
{showManagementActions ? (
<>
<ContextMenuSeparator />
<ContextMenuItem
onSelect={() =>
deferMenuAction(() =>
openChannelManagement(channel.id, { edit: true }),
)
}
>
<ContextMenuIconSlot>
<Pencil className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Edit channel</span>
</ContextMenuItem>
<ContextMenuItem
className="text-destructive focus:text-destructive"
onSelect={() =>
deferMenuAction(() => {
void archiveChannelMutation.mutateAsync().catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Failed to archive channel.",
);
});
})
}
>
<ContextMenuIconSlot>
<Archive className="h-4 w-4" />
</ContextMenuIconSlot>
<span>Archive channel</span>
</ContextMenuItem>
</>
) : null}
{showReadToggle ? <ContextMenuSeparator /> : null}
{hasUnread && onMarkChannelRead ? (
<ContextMenuItem
+69
View File
@@ -2333,6 +2333,75 @@ test("open channel management supports join and leave", async ({ page }) => {
).toBeVisible();
});
test("channel context menu edits a stream", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click({ button: "right" });
const editItem = page.getByRole("menuitem", { name: "Edit channel" });
await expect(editItem).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toBeVisible();
await editItem.click();
const editDialog = page.getByRole("dialog", { name: "Edit channel" });
await expect(editDialog).toBeVisible();
await expect(editDialog.getByTestId("channel-management-name")).toHaveValue(
"general",
);
await editDialog.getByTestId("channel-management-name").fill("general-chat");
await editDialog.getByTestId("channel-management-save-changes").click();
await expect(editDialog).not.toBeVisible();
await expect(page.getByTestId("stream-list")).toContainText("general-chat");
await page.getByRole("button", { name: "Close" }).click();
await page.getByTestId("channel-management-trigger").click();
await expect(editDialog).toHaveCount(0);
});
test("channel context menu hides management actions from members and DMs", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("channel-random").click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Edit channel" }),
).toHaveCount(0);
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toHaveCount(0);
await page.keyboard.press("Escape");
const firstDm = page
.getByTestId("dm-list")
.locator('[data-testid^="channel-"]')
.first();
await firstDm.click({ button: "right" });
await expect(
page.getByRole("menuitem", { name: "Edit channel" }),
).toHaveCount(0);
await expect(
page.getByRole("menuitem", { name: "Archive channel" }),
).toHaveCount(0);
});
test("channel context menu archives a stream", async ({ page }) => {
await page.goto("/");
await page.getByTestId("channel-general").click({ button: "right" });
const archiveItem = page.getByRole("menuitem", { name: "Archive channel" });
await expect(archiveItem).toBeVisible();
await archiveItem.click();
await expect(page.getByTestId("stream-list")).not.toContainText("general");
await openChannelBrowser(page);
await expect(page.getByTestId("browse-channel-general")).toContainText(
"archived",
);
});
test("manage channel can archive and unarchive a stream", async ({ page }) => {
await page.goto("/");
await openChannelManagement(page, "general");