From 4bed899be190dd527b054282326414de4cd7a51a Mon Sep 17 00:00:00 2001 From: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Date: Mon, 13 Jul 2026 17:45:20 -0700 Subject: [PATCH] feat(desktop): add channel management menu actions Co-authored-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e Signed-off-by: npub1ux8n2yfs8qfvgd75s7kyhar2mztac355v6vmrz4juc9l3msw4pgstums9e --- desktop/src/app/AppShell.tsx | 12 ++- desktop/src/app/AppShellContext.tsx | 5 +- desktop/src/app/AppShellOverlays.tsx | 3 + .../channels/ui/ChannelManagementSheet.tsx | 5 +- .../sidebar/ui/ChannelContextMenu.tsx | 79 +++++++++++++++++++ desktop/tests/e2e/channels.spec.ts | 69 ++++++++++++++++ 6 files changed, 170 insertions(+), 3 deletions(-) diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 905235aa8..1b12c7eeb 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -106,6 +106,8 @@ export function AppShell() { const [managedChannelId, setManagedChannelId] = React.useState( null, ); + const [openChannelManagementInEditMode, setOpenChannelManagementInEditMode] = + React.useState(false); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(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={() => { diff --git a/desktop/src/app/AppShellContext.tsx b/desktop/src/app/AppShellContext.tsx index 30d67fd56..1ef289310 100644 --- a/desktop/src/app/AppShellContext.tsx +++ b/desktop/src/app/AppShellContext.tsx @@ -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. diff --git a/desktop/src/app/AppShellOverlays.tsx b/desktop/src/app/AppShellOverlays.tsx index 514492a29..ebb9bb3da 100644 --- a/desktop/src/app/AppShellOverlays.tsx +++ b/desktop/src/app/AppShellOverlays.tsx @@ -21,6 +21,7 @@ type AppShellOverlaysProps = { channels: Channel[]; currentPubkey?: string; isChannelManagementOpen: boolean; + openChannelManagementInEditMode: boolean; onBrowseChannelJoin: (channelId: string) => Promise; 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({ 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; diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index b965aed08..1f6777f15 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -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 ? ( + <> + + + deferMenuAction(() => + openChannelManagement(channel.id, { edit: true }), + ) + } + > + + + + Edit channel + + + deferMenuAction(() => { + void archiveChannelMutation.mutateAsync().catch((error) => { + toast.error( + error instanceof Error + ? error.message + : "Failed to archive channel.", + ); + }); + }) + } + > + + + + Archive channel + + + ) : null} {showReadToggle ? : null} {hasUnread && onMarkChannelRead ? ( { ).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");