diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 905235aa8..55011f5e1 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 [channelManagementRequest, setChannelManagementRequest] = + React.useState({ edit: false, id: 0 }); const [searchFocusRequest, setSearchFocusRequest] = React.useState(0); const [browseDialogType, setBrowseDialogType] = React.useState(null); @@ -637,10 +639,17 @@ export function AppShell() { markChannelRead, markChannelUnread, openCreateChannel: handleOpenCreateChannel, - openChannelManagement: (channelId?: string) => { + openChannelManagement: ( + channelId?: string, + options?: { edit?: boolean }, + ) => { setManagedChannelId( typeof channelId === "string" ? channelId : null, ); + setChannelManagementRequest((request) => ({ + edit: options?.edit === true, + id: request.id + 1, + })); setIsChannelManagementOpen(true); }, getChannelReadAt, @@ -906,6 +915,7 @@ export function AppShell() { channels={channels} currentPubkey={identityQuery.data?.pubkey} isChannelManagementOpen={isChannelManagementOpen} + channelManagementRequest={channelManagementRequest} onBrowseChannelJoin={handleBrowseChannelJoin} onBrowseDialogOpenChange={handleBrowseDialogOpenChange} onChannelManagementOpenChange={(open) => { 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..bf408a9fa 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; + channelManagementRequest: { edit: boolean; id: number }; onBrowseChannelJoin: (channelId: string) => Promise; onBrowseDialogOpenChange: (open: boolean) => void; onChannelManagementOpenChange: (open: boolean) => void; @@ -34,6 +35,7 @@ export function AppShellOverlays({ channels, currentPubkey, isChannelManagementOpen, + channelManagementRequest, 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, + managementRequest = { edit: false, id: 0 }, layout = "overlay", onDeleted, onOpenChange, @@ -201,10 +203,12 @@ export function ChannelManagementSheet({ // Sync drafts from server only when the sheet opens or the channel changes - // not on every background refetch, which would clobber in-flight edits. const syncedForRef = React.useRef(null); + const appliedEditRequestRef = React.useRef(0); React.useEffect(() => { if (!open) { // Reset on close so the next open re-syncs from server. syncedForRef.current = null; + appliedEditRequestRef.current = managementRequest.id; setIsDeleteDialogOpen(false); setIsEditDialogOpen(false); setActiveView("summary"); @@ -215,22 +219,25 @@ export function ChannelManagementSheet({ } const key = detail.id; - if (syncedForRef.current === key) { - return; + if (syncedForRef.current !== key) { + syncedForRef.current = key; + setNameDraft(detail.name); + setDescriptionDraft(detail.description); + setTopicDraft(detail.topic ?? ""); + setPurposeDraft(detail.purpose ?? ""); + setIsPrivateDraft(detail.visibility === "private"); + setIsEphemeralDraft(detail.ttlSeconds !== null); + setTtlDraft( + detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", + ); + setActiveView("summary"); } - syncedForRef.current = key; - - setNameDraft(detail.name); - setDescriptionDraft(detail.description); - setTopicDraft(detail.topic ?? ""); - setPurposeDraft(detail.purpose ?? ""); - setIsPrivateDraft(detail.visibility === "private"); - setIsEphemeralDraft(detail.ttlSeconds !== null); - setTtlDraft( - detail.ttlSeconds !== null ? formatTtlDuration(detail.ttlSeconds) : "", - ); - setActiveView("summary"); - }, [detail, open]); + + if (managementRequest.id !== appliedEditRequestRef.current) { + appliedEditRequestRef.current = managementRequest.id; + setIsEditDialogOpen(managementRequest.edit); + } + }, [detail, managementRequest.edit, managementRequest.id, 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..00664ebdb 100644 --- a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx +++ b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx @@ -1,4 +1,7 @@ +import * as React from "react"; + import { + Archive, Bell, BellOff, Check, @@ -6,11 +9,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, @@ -20,13 +34,35 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Channel } from "@/shared/api/types"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { + ContextMenu, + ContextMenuContent, ContextMenuItem, ContextMenuSeparator, + ContextMenuTrigger, ContextMenuSub, ContextMenuSubContent, ContextMenuSubTrigger, } from "@/shared/ui/context-menu"; +export function ChannelContextMenu({ + children, + contentProps, + modal, +}: { + children: React.ReactNode; + contentProps: React.ComponentProps; + modal?: boolean; +}) { + return ( + + {children} + + + + + ); +} + function MoveToSectionSubmenu({ channelId, sections, @@ -166,6 +202,52 @@ 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 canManageChannel = + selfMember?.role === "owner" || + selfMember?.role === "admin" || + canManageOwnedAgentChannel; + const hasResolvedMembers = + membersQuery.data !== undefined || membersQuery.isError; + const hasResolvedOwnerProfiles = + ownerPubkeys.length === 0 || + ownerProfilesQuery.data !== undefined || + ownerProfilesQuery.isError; + const isManagementCandidate = + channel.channelType !== "dm" && channel.archivedAt === null; + // Context-menu content mounts per open session. Once these rows are reserved, + // keep them until close so permission resolution cannot move another action + // beneath the pointer; eligible rows transition from disabled to enabled. + const [showManagementActions] = React.useState( + () => + isManagementCandidate && + (!hasResolvedMembers || !hasResolvedOwnerProfiles || canManageChannel), + ); + const disableManagementActions = !canManageChannel; const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -192,6 +274,44 @@ 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 ? ( when it closes as the dialog mounts, freezing the // whole app. Non-modal avoids installing that body guard entirely. - - - - {draggable ? ( - - - - ) : ( + + + {draggable ? ( + - )} - - - - - - + + ) : ( + + )} + + ))} ) : null; @@ -705,52 +702,49 @@ export function CustomChannelSection({ // modal={false}: see note on the other channel ContextMenu // above — avoids the pointer-events lockup when Leave // channel's AlertDialog opens. - - - - - - - - - - - - + + + + + + + ))} ) : null} diff --git a/desktop/src/features/sidebar/ui/SidebarSection.tsx b/desktop/src/features/sidebar/ui/SidebarSection.tsx index 84b4a7a27..2bf4e603a 100644 --- a/desktop/src/features/sidebar/ui/SidebarSection.tsx +++ b/desktop/src/features/sidebar/ui/SidebarSection.tsx @@ -9,13 +9,7 @@ import { X, } from "lucide-react"; -import { - ContextMenu, - ContextMenuContent, - ContextMenuTrigger, -} from "@/shared/ui/context-menu"; - -import { ChannelContextMenuItems } from "@/features/sidebar/ui/ChannelContextMenu"; +import { ChannelContextMenu } from "@/features/sidebar/ui/ChannelContextMenu"; import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore"; import { formatElapsed } from "@/features/agents/ui/agentSessionUtils"; import { getEphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; @@ -488,20 +482,20 @@ export function SidebarSection({ // The shared menu always renders copy actions, so every row // gets a context menu regardless of read/mute availability. return ( - - {menuItem} - - - - + + {menuItem} + ); })} diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index 41054deab..c36db5456 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -143,6 +143,7 @@ type E2eConfig = { * tests can observe the in-flight prepend window. 0/undefined = instant. */ channelWindowDelayMs?: number; profileReadDelayMs?: number; + channelMembersDelayMs?: number; profileReadError?: string; profileUpdateError?: string; profileUpdateErrors?: string[]; @@ -5503,6 +5504,11 @@ async function handleGetChannelMembers( args: { channelId: string }, config: E2eConfig | undefined, ): Promise { + const delayMs = config?.mock?.channelMembersDelayMs ?? 0; + if (delayMs > 0) { + await new Promise((resolve) => window.setTimeout(resolve, delayMs)); + } + const identity = getIdentity(config); if (!identity) { const channel = getMockChannel(args.channelId); diff --git a/desktop/tests/e2e/channels.spec.ts b/desktop/tests/e2e/channels.spec.ts index 48894a552..f8516ab1e 100644 --- a/desktop/tests/e2e/channels.spec.ts +++ b/desktop/tests/e2e/channels.spec.ts @@ -11,6 +11,7 @@ import { const GENERAL_CHANNEL_ID = "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50"; const AGENTS_CHANNEL_ID = "94a444a4-c0a3-5966-ab05-530c6ddc2301"; +const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d"; const MOCK_IDENTITY_PUBKEY = "deadbeef".repeat(8); // Relay-only agent owned by the mock viewer (see e2eBridge.ts // OWNED_RELAY_AGENT_PUBKEY). Classified as a bot via mockRelayAgents and @@ -2333,6 +2334,103 @@ test("open channel management supports join and leave", async ({ page }) => { ).toBeVisible(); }); +test("channel context menu edits a stream", async ({ page }) => { + await installMockBridge(page, { channelMembersDelayMs: 2_000 }); + await page.goto("/"); + + await page.getByTestId("channel-agents").click({ button: "right" }); + const pendingEditItem = page.getByRole("menuitem", { name: "Edit channel" }); + await expect(pendingEditItem).toBeVisible(); + await expect(pendingEditItem).toBeDisabled(); + await expect(pendingEditItem).toBeEnabled(); + await page.keyboard.press("Escape"); + await expect(pendingEditItem).toHaveCount(0); + + 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(); + await expect(page.getByTestId("chat-title")).toHaveText("random"); + await page.getByTestId("channel-management-trigger").click(); + await expect(page.getByTestId("channel-management-sheet")).toBeVisible(); + await expect + .poll(async () => { + const commandLog = await readCommandPayloadLog(page); + return commandLog.some( + ({ command, payload }) => + command === "get_channel_members" && + (payload as { channelId?: string }).channelId === RANDOM_CHANNEL_ID, + ); + }) + .toBe(true); + await expect(page.getByTestId("channel-management-edit")).toHaveCount(0); + await page.getByTestId("auxiliary-panel-close").click(); + + await page.getByTestId("channel-random").click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Copy" })).toBeVisible(); + 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: "Copy" })).toBeVisible(); + 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("channel-general")).toHaveCount(0); + 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"); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 27e3fef13..486c6dbb7 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -168,6 +168,7 @@ type MockBridgeOptions = { /** Delay (ms) for older-history fetches; see e2eBridge mock config. */ channelWindowDelayMs?: number; profileReadDelayMs?: number; + channelMembersDelayMs?: number; profileReadError?: string; profileUpdateError?: string; profileUpdateErrors?: string[];