From 079f45f2cbb8375a6eb31aee15df88dd521c43ce Mon Sep 17 00:00:00 2001 From: Pavlenex <36959754+pavlenex@users.noreply.github.com> Date: Sat, 18 Jul 2026 12:54:13 +0500 Subject: [PATCH] feat(desktop): add channel archive and delete actions Signed-off-by: Pavlenex <36959754+pavlenex@users.noreply.github.com> --- .../ui/ChannelManagementModerationActions.tsx | 173 +++++++++++++----- .../channels/ui/ChannelManagementSheet.tsx | 41 +---- .../src/features/sidebar/ui/AppSidebar.tsx | 10 + .../sidebar/ui/ChannelContextMenu.tsx | 98 ++++++++-- .../sidebar/ui/ChannelSectionDialogs.tsx | 37 +++- .../sidebar/ui/CustomChannelSection.tsx | 6 + desktop/tests/e2e/sidebar.spec.ts | 81 ++++++++ 7 files changed, 351 insertions(+), 95 deletions(-) diff --git a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx index 4e11996dc9..a3264cf7a9 100644 --- a/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementModerationActions.tsx @@ -1,6 +1,11 @@ import { Archive, ArchiveRestore, Trash2 } from "lucide-react"; +import { useMemo, type ReactNode } from "react"; +import { ownsAuthorAgent } from "@/features/profile/lib/identity"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import type { ChannelMember } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; +import { normalizePubkey } from "@/shared/lib/pubkey"; import { AlertDialog, AlertDialogAction, @@ -34,6 +39,117 @@ type ChannelManagementModerationActionsProps = { unarchiveChannelMutation: ChannelMutation; }; +type ChannelDeleteConfirmationDialogProps = { + channelName: string; + error: unknown; + isPending: boolean; + onConfirm: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + trigger?: ReactNode; +}; + +export function useChannelModerationCapabilities( + members: ChannelMember[] | undefined, + currentPubkey: string | undefined, + enabled: boolean, +) { + const normalizedCurrentPubkey = currentPubkey + ? normalizePubkey(currentPubkey) + : undefined; + const selfRole = members?.find( + (member) => normalizePubkey(member.pubkey) === normalizedCurrentPubkey, + )?.role; + const ownerMemberPubkeys = useMemo( + () => + (members ?? []) + .filter( + (member) => + member.role === "owner" && + normalizePubkey(member.pubkey) !== normalizedCurrentPubkey, + ) + .map((member) => member.pubkey), + [members, normalizedCurrentPubkey], + ); + const shouldResolveAgentOwnership = + enabled && selfRole !== "owner" && ownerMemberPubkeys.length > 0; + const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, { + enabled: shouldResolveAgentOwnership, + }); + const canManageOwnedAgentChannel = ownerMemberPubkeys.some((pubkey) => + ownsAuthorAgent( + ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)], + currentPubkey, + ), + ); + + return { + canDeleteChannel: selfRole === "owner" || canManageOwnedAgentChannel, + canManageChannel: + selfRole === "owner" || + selfRole === "admin" || + canManageOwnedAgentChannel, + error: shouldResolveAgentOwnership ? ownerProfilesQuery.error : null, + isLoading: shouldResolveAgentOwnership && ownerProfilesQuery.isLoading, + }; +} + +export function ChannelDeleteConfirmationDialog({ + channelName, + error, + isPending, + onConfirm, + onOpenChange, + open, + trigger, +}: ChannelDeleteConfirmationDialogProps) { + return ( + + {trigger ? ( + {trigger} + ) : null} + + + Delete channel? + + Delete {channelName} from the community list. This action cannot be + undone. + + + {error instanceof Error ? ( +

{error.message}

+ ) : null} + + + + + + + + +
+
+ ); +} + export function ChannelManagementModerationActions({ archiveChannelMutation, canManageChannel, @@ -105,11 +221,16 @@ export function ChannelManagementModerationActions({ )} {canDeleteChannel ? ( - { + void handleDeleteChannel(); + }} onOpenChange={handleDeleteDialogOpenChange} open={isDeleteDialogOpen} - > - + trigger={ - - - - Delete channel? - - Delete {resolvedChannelName} from the community list. This - action cannot be undone. - - - {deleteChannelMutation.error instanceof Error ? ( -

- {deleteChannelMutation.error.message} -

- ) : null} - - - - - - - - -
-
+ } + /> ) : null} ); diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 1bf08a1103..57c1f6c140 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -38,11 +38,8 @@ import { formatTtlDuration, parseTtlDuration, } from "@/features/channels/lib/ephemeralChannel"; -import { ownsAuthorAgent } from "@/features/profile/lib/identity"; -import { useUsersBatchQuery } from "@/features/profile/hooks"; import type { Channel } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; -import { normalizePubkey } from "@/shared/lib/pubkey"; import { useTheme } from "@/shared/theme/ThemeProvider"; import { Button } from "@/shared/ui/button"; import { @@ -83,7 +80,10 @@ import { NarrativeGroup, ToggleRow, } from "./ChannelManagementSheetRows"; -import { ChannelManagementModerationActions } from "./ChannelManagementModerationActions"; +import { + ChannelManagementModerationActions, + useChannelModerationCapabilities, +} from "./ChannelManagementModerationActions"; import { writeTextToClipboard } from "@/shared/lib/clipboard"; type ChannelManagementSheetProps = { @@ -137,37 +137,8 @@ export function ChannelManagementSheet({ members.find((member) => member.pubkey === currentPubkey) ?? null; const hasResolvedMembership = membersQuery.data !== undefined; - // Collect owner-role member pubkeys to look up their NIP-OA ownerPubkey. - // This is what surfaces the "you own the agent that owns this channel" path. - const ownerMemberPubkeys = React.useMemo( - () => - members - .filter((m) => m.role === "owner" && m.pubkey !== currentPubkey) - .map((m) => m.pubkey), - [members, currentPubkey], - ); - const ownerProfilesQuery = useUsersBatchQuery(ownerMemberPubkeys, { - enabled: open && ownerMemberPubkeys.length > 0, - }); - // True when an owner-role member of this channel is an agent owned by the - // current user — mirrors the relay's is_agent_owner gate. - const canManageOwnedAgentChannel = React.useMemo(() => { - if (!currentPubkey || !ownerProfilesQuery.data) return false; - return ownerMemberPubkeys.some((pubkey) => - ownsAuthorAgent( - ownerProfilesQuery.data?.profiles[normalizePubkey(pubkey)], - currentPubkey, - ), - ); - }, [currentPubkey, ownerMemberPubkeys, ownerProfilesQuery.data]); - - const isSelfOwner = selfMember?.role === "owner"; - // Capability: may delete this channel (self-owner OR owns the agent-owner). - const canDeleteChannel = isSelfOwner || canManageOwnedAgentChannel; - const canManageChannel = - selfMember?.role === "owner" || - selfMember?.role === "admin" || - canManageOwnedAgentChannel; + const { canDeleteChannel, canManageChannel } = + useChannelModerationCapabilities(membersQuery.data, currentPubkey, open); const canEditNarrative = canManageChannel && selfMember !== null && detail?.channelType !== "dm"; const isArchived = diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index cf0e65bc7a..0165342a1e 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -26,6 +26,7 @@ import { CreateSectionDialog, DeleteSectionAlertDialog, RenameSectionDialog, + useDeleteChannelDialog, useLeaveChannelDialog, type SectionDialogValue, } from "@/features/sidebar/ui/ChannelSectionDialogs"; @@ -373,6 +374,10 @@ export function AppSidebar({ React.useState(null); const { requestLeaveChannel, dialog: leaveChannelDialog } = useLeaveChannelDialog(); + const { requestDeleteChannel, dialog: deleteChannelDialog } = + useDeleteChannelDialog((channel) => { + if (channel.id === selectedChannelId) onSelectHome(); + }); const streamChannels = React.useMemo( () => channels.filter((channel) => channel.channelType === "stream"), @@ -635,6 +640,7 @@ export function AppSidebar({ starredChannelIds={starredChannelIds} onStarChannel={onStarChannel} onUnstarChannel={onUnstarChannel} + onDeleteChannel={requestDeleteChannel} onLeaveChannel={requestLeaveChannel} /> ) : null} @@ -704,6 +710,7 @@ export function AppSidebar({ starredChannelIds={starredChannelIds} onStarChannel={onStarChannel} onUnstarChannel={onUnstarChannel} + onDeleteChannel={requestDeleteChannel} onLeaveChannel={requestLeaveChannel} /> ))} @@ -743,6 +750,7 @@ export function AppSidebar({ starredChannelIds={starredChannelIds} onStarChannel={onStarChannel} onUnstarChannel={onUnstarChannel} + onDeleteChannel={requestDeleteChannel} onLeaveChannel={requestLeaveChannel} /> @@ -773,6 +781,7 @@ export function AppSidebar({ mutedChannelIds={mutedChannelIds} onMuteChannel={onMuteChannel} onUnmuteChannel={onUnmuteChannel} + onDeleteChannel={requestDeleteChannel} /> + {deleteChannelDialog} {leaveChannelDialog} diff --git a/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx b/desktop/src/features/sidebar/ui/ChannelContextMenu.tsx index b965aed086..76cae3ed38 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,19 @@ import { CircleDot, Copy, LogOut, + LoaderCircle, Plus, Star, StarOff, + Trash2, + TriangleAlert, } from "lucide-react"; +import { + useArchiveChannelMutation, + useChannelMembersQuery, +} from "@/features/channels/hooks"; +import { useChannelModerationCapabilities } from "@/features/channels/ui/ChannelManagementModerationActions"; import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections"; import { ContextMenuIconSlot, @@ -18,6 +27,7 @@ import { } from "@/features/sidebar/ui/sidebarMenuHelpers"; import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import type { Channel } from "@/shared/api/types"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { copyTextToClipboard } from "@/shared/lib/clipboard"; import { ContextMenuItem, @@ -144,6 +154,7 @@ export function ChannelContextMenuItems({ onAssignChannel, onUnassignChannel, onCreateSectionForChannel, + onDeleteChannel, onLeaveChannel, }: { channel: Channel; @@ -164,8 +175,34 @@ export function ChannelContextMenuItems({ onAssignChannel?: (channelId: string, sectionId: string) => void; onUnassignChannel?: (channelId: string) => void; onCreateSectionForChannel?: (channelId: string) => void; + onDeleteChannel?: (channel: Channel) => void; onLeaveChannel?: (channel: Channel) => void; }) { + const canLoadOwnerActions = + channel.channelType !== "dm" && Boolean(onDeleteChannel); + const membersQuery = useChannelMembersQuery(channel.id, canLoadOwnerActions); + const currentPubkey = useIdentityQuery().data?.pubkey; + const archiveChannel = useArchiveChannelMutation(channel.id); + const { + canDeleteChannel, + canManageChannel, + error: capabilityError, + isLoading: isCapabilityLoading, + } = useChannelModerationCapabilities( + membersQuery.data, + currentPubkey, + canLoadOwnerActions, + ); + const ownerActionsError = membersQuery.error ?? capabilityError; + const ownerActionsLoading = + canLoadOwnerActions && (membersQuery.isLoading || isCapabilityLoading); + const showChannelActions = Boolean( + onLeaveChannel || + ownerActionsLoading || + ownerActionsError || + canManageChannel || + canDeleteChannel, + ); const showStar = Boolean(onStarChannel && onUnstarChannel); const showReadToggle = hasUnread ? Boolean(onMarkChannelRead) @@ -265,19 +302,56 @@ export function ChannelContextMenuItems({ ) ) : null} + {showChannelActions ? : null} {onLeaveChannel ? ( - <> - - deferMenuAction(() => onLeaveChannel(channel))} - > - - - - Leave channel - - + deferMenuAction(() => onLeaveChannel(channel))} + > + + + + Leave channel + + ) : null} + {ownerActionsLoading ? ( + + + + + Loading channel actions... + + ) : ownerActionsError ? ( + + + + + Channel actions unavailable + + ) : null} + {canManageChannel ? ( + deferMenuAction(() => archiveChannel.mutate())} + > + + + + Archive channel + + ) : null} + {canDeleteChannel ? ( + deferMenuAction(() => onDeleteChannel?.(channel))} + > + + + + Delete channel + ) : null} ); diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx index baa230971b..6b054fa600 100644 --- a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx +++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx @@ -26,7 +26,11 @@ import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji"; import { Input } from "@/shared/ui/input"; import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; import type { Channel } from "@/shared/api/types"; -import { useLeaveChannelMutation } from "@/features/channels/hooks"; +import { + useDeleteChannelMutation, + useLeaveChannelMutation, +} from "@/features/channels/hooks"; +import { ChannelDeleteConfirmationDialog } from "@/features/channels/ui/ChannelManagementModerationActions"; export type SectionDialogValue = { name: string; @@ -335,3 +339,34 @@ export function useLeaveChannelDialog() { return { requestLeaveChannel: setTarget, dialog }; } + +export function useDeleteChannelDialog(onDeleted: (channel: Channel) => void) { + const [target, setTarget] = React.useState(null); + const deleteChannel = useDeleteChannelMutation(target?.id ?? null); + + const dialog = ( + { + const deletedChannel = target; + deleteChannel.mutate(undefined, { + onSuccess: () => { + setTarget(null); + if (deletedChannel) onDeleted(deletedChannel); + }, + }); + }} + onOpenChange={(open) => { + if (!open) { + deleteChannel.reset(); + setTarget(null); + } + }} + open={target !== null} + /> + ); + + return { requestDeleteChannel: setTarget, dialog }; +} diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx index 2b266e887e..7e17ce02ca 100644 --- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx +++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx @@ -371,6 +371,7 @@ export function ChannelGroupSection({ starredChannelIds, onStarChannel, onUnstarChannel, + onDeleteChannel, onLeaveChannel, }: { browseLabel?: string; @@ -420,6 +421,7 @@ export function ChannelGroupSection({ starredChannelIds?: ReadonlySet; onStarChannel?: (channelId: string) => void; onUnstarChannel?: (channelId: string) => void; + onDeleteChannel?: (channel: Channel) => void; onLeaveChannel?: (channel: Channel) => void; }) { const contentId = `sidebar-${listTestId}`; @@ -478,6 +480,7 @@ export function ChannelGroupSection({ onAssignChannel={onAssignChannel} onUnassignChannel={onUnassignChannel} onCreateSectionForChannel={onCreateSectionForChannel} + onDeleteChannel={onDeleteChannel} onLeaveChannel={onLeaveChannel} /> @@ -572,6 +575,7 @@ export function CustomChannelSection({ starredChannelIds, onStarChannel, onUnstarChannel, + onDeleteChannel, onLeaveChannel, }: { section: ChannelSection; @@ -611,6 +615,7 @@ export function CustomChannelSection({ starredChannelIds?: ReadonlySet; onStarChannel?: (channelId: string) => void; onUnstarChannel?: (channelId: string) => void; + onDeleteChannel?: (channel: Channel) => void; onLeaveChannel?: (channel: Channel) => void; }) { const contentId = `sidebar-section-${section.id}`; @@ -770,6 +775,7 @@ export function CustomChannelSection({ onCreateSectionForChannel={ onCreateSectionForChannel } + onDeleteChannel={onDeleteChannel} onLeaveChannel={onLeaveChannel} /> diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts index 6945c54151..0234531f81 100644 --- a/desktop/tests/e2e/sidebar.spec.ts +++ b/desktop/tests/e2e/sidebar.spec.ts @@ -134,6 +134,87 @@ test("leaving a channel from the context menu never freezes the app", async ({ await expectAppClickable(page); }); +test("channel context menu only shows owner actions to the owner", async ({ + page, +}) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Delete channel" }), + ).toBeVisible(); + const lifecycleOrder = ( + await page.getByRole("menuitem").allTextContents() + ).filter((label) => + ["Leave channel", "Archive channel", "Delete channel"].includes(label), + ); + expect(lifecycleOrder).toEqual([ + "Leave channel", + "Archive channel", + "Delete channel", + ]); + await page.keyboard.press("Escape"); + + await page.getByTestId("channel-random").click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Leave channel" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Loading channel actions..." }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toHaveCount(0); + await expect( + page.getByRole("menuitem", { name: "Delete channel" }), + ).toHaveCount(0); +}); + +test("channel context menu explains when owner actions are loading", async ({ + page, +}) => { + await installMockBridge(page, { channelMembersReadDelayMs: 500 }); + await page.goto("/"); + + await page.getByTestId("channel-general").click({ button: "right" }); + await expect( + page.getByRole("menuitem", { name: "Loading channel actions..." }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Archive channel" }), + ).toBeVisible(); + await expect( + page.getByRole("menuitem", { name: "Loading channel actions..." }), + ).toHaveCount(0); +}); + +test("channel owner can archive from the context menu", async ({ page }) => { + await page.goto("/"); + + await page.getByTestId("channel-general").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Archive channel" }).click(); + + await expect(page.getByTestId("stream-list")).not.toContainText("general"); +}); + +test("channel owner can delete from the context menu", async ({ page }) => { + await page.goto("/"); + await page.getByTestId("channel-general").click(); + + await page.getByTestId("channel-general").click({ button: "right" }); + await page.getByRole("menuitem", { name: "Delete channel" }).click(); + await expect( + page.getByTestId("channel-delete-confirmation-dialog"), + ).toBeVisible(); + await page.getByTestId("channel-delete-confirm").click(); + + await expect(page.getByTestId("home-inbox-list")).toBeVisible(); + await expect(page.getByTestId("stream-list")).not.toContainText("general"); +}); + test("fades the pinned sidebar chrome edges outside the Buzz theme", async ({ page, }) => {